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"
 | 
						"path/filepath"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/rs/zerolog/log"
 | 
						"github.com/rs/zerolog/log"
 | 
				
			||||||
	"github.com/tailscale/hujson"
 | 
						"github.com/tailscale/hujson"
 | 
				
			||||||
@ -120,6 +121,16 @@ func (h *Headscale) UpdateACLRules() error {
 | 
				
			|||||||
	log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
 | 
						log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
 | 
				
			||||||
	h.aclRules = rules
 | 
						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
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -187,6 +198,111 @@ func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
 | 
				
			|||||||
	return rules, nil
 | 
						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(
 | 
					func (h *Headscale) generateACLPolicySrcIP(
 | 
				
			||||||
	machines []Machine,
 | 
						machines []Machine,
 | 
				
			||||||
	aclPolicy ACLPolicy,
 | 
						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)
 | 
						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) {
 | 
					func (s *Suite) TestInvalidGroupInGroup(c *check.C) {
 | 
				
			||||||
	// this ACL is wrong because the group in Sources sections doesn't exist
 | 
						// this ACL is wrong because the group in Sources sections doesn't exist
 | 
				
			||||||
	app.aclPolicy = &ACLPolicy{
 | 
						app.aclPolicy = &ACLPolicy{
 | 
				
			||||||
 | 
				
			|||||||
@ -17,6 +17,7 @@ type ACLPolicy struct {
 | 
				
			|||||||
	ACLs          []ACL         `json:"acls"          yaml:"acls"`
 | 
						ACLs          []ACL         `json:"acls"          yaml:"acls"`
 | 
				
			||||||
	Tests         []ACLTest     `json:"tests"         yaml:"tests"`
 | 
						Tests         []ACLTest     `json:"tests"         yaml:"tests"`
 | 
				
			||||||
	AutoApprovers AutoApprovers `json:"autoApprovers" yaml:"autoApprovers"`
 | 
						AutoApprovers AutoApprovers `json:"autoApprovers" yaml:"autoApprovers"`
 | 
				
			||||||
 | 
						SSHs          []SSH         `json:"ssh"           yaml:"ssh"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// ACL is a basic rule for the ACL Policy.
 | 
					// ACL is a basic rule for the ACL Policy.
 | 
				
			||||||
@ -50,6 +51,15 @@ type AutoApprovers struct {
 | 
				
			|||||||
	ExitNode []string            `json:"exitNode" yaml:"exitNode"`
 | 
						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.
 | 
					// UnmarshalJSON allows to parse the Hosts directly into netip objects.
 | 
				
			||||||
func (hosts *Hosts) UnmarshalJSON(data []byte) error {
 | 
					func (hosts *Hosts) UnmarshalJSON(data []byte) error {
 | 
				
			||||||
	newHosts := Hosts{}
 | 
						newHosts := Hosts{}
 | 
				
			||||||
 | 
				
			|||||||
@ -62,6 +62,7 @@ func (h *Headscale) generateMapResponse(
 | 
				
			|||||||
		DNSConfig:    dnsConfig,
 | 
							DNSConfig:    dnsConfig,
 | 
				
			||||||
		Domain:       h.cfg.BaseDomain,
 | 
							Domain:       h.cfg.BaseDomain,
 | 
				
			||||||
		PacketFilter: h.aclRules,
 | 
							PacketFilter: h.aclRules,
 | 
				
			||||||
 | 
							SSHPolicy:    h.sshPolicy,
 | 
				
			||||||
		DERPMap:      h.DERPMap,
 | 
							DERPMap:      h.DERPMap,
 | 
				
			||||||
		UserProfiles: profiles,
 | 
							UserProfiles: profiles,
 | 
				
			||||||
		Debug: &tailcfg.Debug{
 | 
							Debug: &tailcfg.Debug{
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								app.go
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								app.go
									
									
									
									
									
								
							@ -88,6 +88,7 @@ type Headscale struct {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	aclPolicy *ACLPolicy
 | 
						aclPolicy *ACLPolicy
 | 
				
			||||||
	aclRules  []tailcfg.FilterRule
 | 
						aclRules  []tailcfg.FilterRule
 | 
				
			||||||
 | 
						sshPolicy *tailcfg.SSHPolicy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	lastStateChange *xsync.MapOf[string, time.Time]
 | 
						lastStateChange *xsync.MapOf[string, time.Time]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -744,7 +744,11 @@ func (machine Machine) toNode(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		KeepAlive:         true,
 | 
							KeepAlive:         true,
 | 
				
			||||||
		MachineAuthorized: !machine.isExpired(),
 | 
							MachineAuthorized: !machine.isExpired(),
 | 
				
			||||||
		Capabilities:      []string{tailcfg.CapabilityFileSharing},
 | 
							Capabilities: []string{
 | 
				
			||||||
 | 
								tailcfg.CapabilityFileSharing,
 | 
				
			||||||
 | 
								tailcfg.CapabilityAdmin,
 | 
				
			||||||
 | 
								tailcfg.CapabilitySSH,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return &node, nil
 | 
						return &node, nil
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user