diff --git a/CHANGELOG.md b/CHANGELOG.md index 55331646..ffe0ce7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,20 @@ - Log level option `log_level` was moved to a distinct `log` config section and renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768) - Removed Alpine Linux container image [#962](https://github.com/juanfont/headscale/pull/962) -### Changes +### Important Changes - Added support for Tailscale TS2021 protocol [#738](https://github.com/juanfont/headscale/pull/738) +- Add experimental support for [SSH ACL](https://tailscale.com/kb/1018/acls/#tailscale-ssh) (see docs for limitations) [#847](https://github.com/juanfont/headscale/pull/847) + - Please note that this support should be considered _partially_ implemented + - SSH ACLs status: + - Support `accept` and `check` (SSH can be enabled and used for connecting and authentication) + - Rejecting connections **are not supported**, meaning that if you enable SSH, then assume that _all_ `ssh` connections **will be allowed**. + - If you decied to try this feature, please carefully managed permissions by blocking port `22` with regular ACLs or do _not_ set `--ssh` on your clients. + - We are currently improving our testing of the SSH ACLs, help us get an overview by testing and giving feedback. + - This feature should be considered dangerous and it is disabled by default. Enable by setting `HEADSCALE_EXPERIMENTAL_FEATURE_SSH=1`. + +### Changes + - Add ability to specify config location via env var `HEADSCALE_CONFIG` [#674](https://github.com/juanfont/headscale/issues/674) - Target Go 1.19 for Headscale [#778](https://github.com/juanfont/headscale/pull/778) - Target Tailscale v1.30.0 to build Headscale [#780](https://github.com/juanfont/headscale/pull/780) diff --git a/Dockerfile.tailscale b/Dockerfile.tailscale index 145ab6f7..fc02aeaa 100644 --- a/Dockerfile.tailscale +++ b/Dockerfile.tailscale @@ -4,14 +4,16 @@ ARG TAILSCALE_VERSION=* ARG TAILSCALE_CHANNEL=stable RUN apt-get update \ - && apt-get install -y gnupg curl \ + && apt-get install -y gnupg curl ssh \ && curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.gpg | apt-key add - \ && curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \ && apt-get update \ && apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \ && rm -rf /var/lib/apt/lists/* +RUN adduser --shell=/bin/bash ssh-it-user + ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/ -RUN chmod 644 /usr/local/share/ca-certificates/server.crt +RUN chmod 644 /usr/local/share/ca-certificates/server.crt RUN update-ca-certificates diff --git a/Dockerfile.tailscale-HEAD b/Dockerfile.tailscale-HEAD index c6e894da..c9a04189 100644 --- a/Dockerfile.tailscale-HEAD +++ b/Dockerfile.tailscale-HEAD @@ -1,9 +1,10 @@ FROM golang:latest RUN apt-get update \ - && apt-get install -y ca-certificates dnsutils git iptables \ + && apt-get install -y ca-certificates dnsutils git iptables ssh \ && rm -rf /var/lib/apt/lists/* +RUN useradd --shell=/bin/bash --create-home ssh-it-user RUN git clone https://github.com/tailscale/tailscale.git @@ -18,6 +19,6 @@ RUN cp tailscale /usr/local/bin/ RUN cp tailscaled /usr/local/bin/ ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/ -RUN chmod 644 /usr/local/share/ca-certificates/server.crt +RUN chmod 644 /usr/local/share/ca-certificates/server.crt RUN update-ca-certificates diff --git a/acls.go b/acls.go index 0b365c1f..c6b65a1e 100644 --- a/acls.go +++ b/acls.go @@ -10,10 +10,12 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/rs/zerolog/log" "github.com/tailscale/hujson" "gopkg.in/yaml.v3" + "tailscale.com/envknob" "tailscale.com/tailcfg" ) @@ -54,6 +56,8 @@ const ( ProtocolFC = 133 // Fibre Channel ) +var featureEnableSSH = envknob.RegisterBool("HEADSCALE_EXPERIMENTAL_FEATURE_SSH") + // LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules. func (h *Headscale) LoadACLPolicy(path string) error { log.Debug(). @@ -120,6 +124,20 @@ func (h *Headscale) UpdateACLRules() error { log.Trace().Interface("ACL", rules).Msg("ACL rules generated") h.aclRules = rules + if featureEnableSSH() { + 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 + } else if h.aclPolicy != nil && len(h.aclPolicy.SSHs) > 0 { + log.Info().Msg("SSH ACLs has been defined, but HEADSCALE_EXPERIMENTAL_FEATURE_SSH is not enabled, this is a unstable feature, check docs before activating") + } + return nil } @@ -187,6 +205,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, diff --git a/acls_test.go b/acls_test.go index 41f8d39d..23e7f917 100644 --- a/acls_test.go +++ b/acls_test.go @@ -7,6 +7,7 @@ import ( "testing" "gopkg.in/check.v1" + "tailscale.com/envknob" "tailscale.com/tailcfg" ) @@ -73,6 +74,81 @@ func (s *Suite) TestInvalidAction(c *check.C) { c.Assert(errors.Is(err, errInvalidAction), check.Equals, true) } +func (s *Suite) TestSshRules(c *check.C) { + envknob.Setenv("HEADSCALE_EXPERIMENTAL_FEATURE_SSH", "1") + + 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{ diff --git a/acls_types.go b/acls_types.go index 638a456f..da981d38 100644 --- a/acls_types.go +++ b/acls_types.go @@ -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{} diff --git a/api_common.go b/api_common.go index 1eaad57c..75cc1a5f 100644 --- a/api_common.go +++ b/api_common.go @@ -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{ diff --git a/app.go b/app.go index d840fd78..6274d8fd 100644 --- a/app.go +++ b/app.go @@ -88,6 +88,7 @@ type Headscale struct { aclPolicy *ACLPolicy aclRules []tailcfg.FilterRule + sshPolicy *tailcfg.SSHPolicy lastStateChange *xsync.MapOf[string, time.Time] diff --git a/integration/cli_test.go b/integration/cli_test.go index 0ed350cf..f2093226 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -10,6 +10,7 @@ import ( v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" "github.com/stretchr/testify/assert" ) @@ -39,7 +40,7 @@ func TestNamespaceCommand(t *testing.T) { "namespace2": 0, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clins")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clins")) assert.NoError(t, err) headscale, err := scenario.Headscale() @@ -120,7 +121,7 @@ func TestPreAuthKeyCommand(t *testing.T) { namespace: 0, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipak")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipak")) assert.NoError(t, err) headscale, err := scenario.Headscale() @@ -260,7 +261,7 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { namespace: 0, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipaknaexp")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipaknaexp")) assert.NoError(t, err) headscale, err := scenario.Headscale() @@ -325,7 +326,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { namespace: 0, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipakresueeph")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipakresueeph")) assert.NoError(t, err) headscale, err := scenario.Headscale() diff --git a/integration/general_test.go b/integration/general_test.go index be745541..27c62fff 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" "github.com/rs/zerolog/log" ) @@ -24,7 +25,7 @@ func TestPingAllByIP(t *testing.T) { "namespace2": len(TailscaleVersions), } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("pingallbyip")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("pingallbyip")) if err != nil { t.Errorf("failed to create headscale environment: %s", err) } @@ -80,7 +81,7 @@ func TestPingAllByHostname(t *testing.T) { "namespace4": len(TailscaleVersions) - 1, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("pingallbyname")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("pingallbyname")) if err != nil { t.Errorf("failed to create headscale environment: %s", err) } @@ -148,7 +149,7 @@ func TestTaildrop(t *testing.T) { "taildrop": len(TailscaleVersions) - 1, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("taildrop")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("taildrop")) if err != nil { t.Errorf("failed to create headscale environment: %s", err) } @@ -276,7 +277,7 @@ func TestResolveMagicDNS(t *testing.T) { "magicdns2": len(TailscaleVersions) - 1, } - err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("magicdns")) + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("magicdns")) if err != nil { t.Errorf("failed to create headscale environment: %s", err) } diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index 5945c913..9766c882 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -79,13 +79,9 @@ func WithTLS() Option { func WithConfigEnv(configEnv map[string]string) Option { return func(hsic *HeadscaleInContainer) { - env := []string{} - for key, value := range configEnv { - env = append(env, fmt.Sprintf("%s=%s", key, value)) + hsic.env = append(hsic.env, fmt.Sprintf("%s=%s", key, value)) } - - hsic.env = env } } diff --git a/integration/scenario.go b/integration/scenario.go index 20bc4260..0ce4bdf8 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -229,6 +229,7 @@ func (s *Scenario) CreateTailscaleNodesInNamespace( namespaceStr string, requestedVersion string, count int, + opts ...tsic.Option, ) error { if namespace, ok := s.namespaces[namespaceStr]; ok { for i := 0; i < count; i++ { @@ -247,6 +248,11 @@ func (s *Scenario) CreateTailscaleNodesInNamespace( namespace.createWaitGroup.Add(1) + opts = append(opts, + tsic.WithHeadscaleTLS(cert), + tsic.WithHeadscaleName(hostname), + ) + go func() { defer namespace.createWaitGroup.Done() @@ -255,8 +261,7 @@ func (s *Scenario) CreateTailscaleNodesInNamespace( s.pool, version, s.network, - tsic.WithHeadscaleTLS(cert), - tsic.WithHeadscaleName(hostname), + opts..., ) if err != nil { // return fmt.Errorf("failed to add tailscale node: %w", err) @@ -341,7 +346,11 @@ func (s *Scenario) WaitForTailscaleSync() error { // CreateHeadscaleEnv is a conventient method returning a set up Headcale // test environment with nodes of all versions, joined to the server with X // namespaces. -func (s *Scenario) CreateHeadscaleEnv(namespaces map[string]int, opts ...hsic.Option) error { +func (s *Scenario) CreateHeadscaleEnv( + namespaces map[string]int, + tsOpts []tsic.Option, + opts ...hsic.Option, +) error { headscale, err := s.Headscale(opts...) if err != nil { return err @@ -353,7 +362,7 @@ func (s *Scenario) CreateHeadscaleEnv(namespaces map[string]int, opts ...hsic.Op return err } - err = s.CreateTailscaleNodesInNamespace(namespaceName, "all", clientCount) + err = s.CreateTailscaleNodesInNamespace(namespaceName, "all", clientCount, tsOpts...) if err != nil { return err } diff --git a/integration/scenario_test.go b/integration/scenario_test.go index 5504a814..363f8e37 100644 --- a/integration/scenario_test.go +++ b/integration/scenario_test.go @@ -6,7 +6,7 @@ import ( "github.com/juanfont/headscale/integration/dockertestutil" ) -// This file is intendet to "test the test framework", by proxy it will also test +// This file is intended to "test the test framework", by proxy it will also test // some Headcsale/Tailscale stuff, but mostly in very simple ways. func IntegrationSkip(t *testing.T) { diff --git a/integration/ssh_test.go b/integration/ssh_test.go new file mode 100644 index 00000000..47c47333 --- /dev/null +++ b/integration/ssh_test.go @@ -0,0 +1,519 @@ +package integration + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/juanfont/headscale" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" +) + +var retry = func(times int, sleepInterval time.Duration, + doWork func() (string, string, error), +) (string, string, error) { + var result string + var stderr string + var err error + + for attempts := 0; attempts < times; attempts++ { + tempResult, tempStderr, err := doWork() + + result += tempResult + stderr += tempStderr + + if err == nil { + return result, stderr, nil + } + + // If we get a permission denied error, we can fail immediately + // since that is something we wont recover from by retrying. + if err != nil && strings.Contains(stderr, "Permission denied (tailscale)") { + return result, stderr, err + } + + time.Sleep(sleepInterval) + } + + return result, stderr, err +} + +func TestSSHOneNamespaceAllToAll(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + scenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + spec := map[string]int{ + "namespace1": len(TailscaleVersions) - 5, + } + + err = scenario.CreateHeadscaleEnv(spec, + []tsic.Option{tsic.WithSSH()}, + hsic.WithACLPolicy( + &headscale.ACLPolicy{ + Groups: map[string][]string{ + "group:integration-test": {"namespace1"}, + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"*:*"}, + }, + }, + SSHs: []headscale.SSH{ + { + Action: "accept", + Sources: []string{"group:integration-test"}, + Destinations: []string{"group:integration-test"}, + Users: []string{"ssh-it-user"}, + }, + }, + }, + ), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_EXPERIMENTAL_FEATURE_SSH": "1", + }), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + allClients, err := scenario.ListTailscaleClients() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + _, err = scenario.ListTailscaleClientsFQDNs() + if err != nil { + t.Errorf("failed to get FQDNs: %s", err) + } + + for _, client := range allClients { + for _, peer := range allClients { + if client.Hostname() == peer.Hostname() { + continue + } + + assertSSHHostname(t, client, peer) + } + } + + err = scenario.Shutdown() + if err != nil { + t.Errorf("failed to tear down scenario: %s", err) + } +} + +func TestSSHMultipleNamespacesAllToAll(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + scenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + spec := map[string]int{ + "namespace1": len(TailscaleVersions) - 5, + "namespace2": len(TailscaleVersions) - 5, + } + + err = scenario.CreateHeadscaleEnv(spec, + []tsic.Option{tsic.WithSSH()}, + hsic.WithACLPolicy( + &headscale.ACLPolicy{ + Groups: map[string][]string{ + "group:integration-test": {"namespace1", "namespace2"}, + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"*:*"}, + }, + }, + SSHs: []headscale.SSH{ + { + Action: "accept", + Sources: []string{"group:integration-test"}, + Destinations: []string{"group:integration-test"}, + Users: []string{"ssh-it-user"}, + }, + }, + }, + ), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_EXPERIMENTAL_FEATURE_SSH": "1", + }), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + nsOneClients, err := scenario.ListTailscaleClients("namespace1") + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + nsTwoClients, err := scenario.ListTailscaleClients("namespace2") + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + _, err = scenario.ListTailscaleClientsFQDNs() + if err != nil { + t.Errorf("failed to get FQDNs: %s", err) + } + + testInterNamespaceSSH := func(sourceClients []TailscaleClient, targetClients []TailscaleClient) { + for _, client := range sourceClients { + for _, peer := range targetClients { + assertSSHHostname(t, client, peer) + } + } + } + + testInterNamespaceSSH(nsOneClients, nsTwoClients) + testInterNamespaceSSH(nsTwoClients, nsOneClients) + + err = scenario.Shutdown() + if err != nil { + t.Errorf("failed to tear down scenario: %s", err) + } +} + +func TestSSHNoSSHConfigured(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + scenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + spec := map[string]int{ + "namespace1": len(TailscaleVersions) - 5, + } + + err = scenario.CreateHeadscaleEnv(spec, + []tsic.Option{tsic.WithSSH()}, + hsic.WithACLPolicy( + &headscale.ACLPolicy{ + Groups: map[string][]string{ + "group:integration-test": {"namespace1"}, + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"*:*"}, + }, + }, + SSHs: []headscale.SSH{}, + }, + ), + hsic.WithTestName("sshnoneconfigured"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_EXPERIMENTAL_FEATURE_SSH": "1", + }), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + allClients, err := scenario.ListTailscaleClients() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + _, err = scenario.ListTailscaleClientsFQDNs() + if err != nil { + t.Errorf("failed to get FQDNs: %s", err) + } + + for _, client := range allClients { + for _, peer := range allClients { + if client.Hostname() == peer.Hostname() { + continue + } + + assertSSHPermissionDenied(t, client, peer) + } + } + + err = scenario.Shutdown() + if err != nil { + t.Errorf("failed to tear down scenario: %s", err) + } +} + +func TestSSHIsBlockedInACL(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + scenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + spec := map[string]int{ + "namespace1": len(TailscaleVersions) - 5, + } + + err = scenario.CreateHeadscaleEnv(spec, + []tsic.Option{tsic.WithSSH()}, + hsic.WithACLPolicy( + &headscale.ACLPolicy{ + Groups: map[string][]string{ + "group:integration-test": {"namespace1"}, + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"*:80"}, + }, + }, + SSHs: []headscale.SSH{ + { + Action: "accept", + Sources: []string{"group:integration-test"}, + Destinations: []string{"group:integration-test"}, + Users: []string{"ssh-it-user"}, + }, + }, + }, + ), + hsic.WithTestName("sshisblockedinacl"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_EXPERIMENTAL_FEATURE_SSH": "1", + }), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + allClients, err := scenario.ListTailscaleClients() + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + _, err = scenario.ListTailscaleClientsFQDNs() + if err != nil { + t.Errorf("failed to get FQDNs: %s", err) + } + + for _, client := range allClients { + for _, peer := range allClients { + if client.Hostname() == peer.Hostname() { + continue + } + + assertSSHTimeout(t, client, peer) + } + } + + err = scenario.Shutdown() + if err != nil { + t.Errorf("failed to tear down scenario: %s", err) + } +} + +func TestSSNamespaceOnlyIsolation(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + scenario, err := NewScenario() + if err != nil { + t.Errorf("failed to create scenario: %s", err) + } + + spec := map[string]int{ + "namespaceacl1": len(TailscaleVersions) - 5, + "namespaceacl2": len(TailscaleVersions) - 5, + } + + err = scenario.CreateHeadscaleEnv(spec, + []tsic.Option{tsic.WithSSH()}, + hsic.WithACLPolicy( + &headscale.ACLPolicy{ + Groups: map[string][]string{ + "group:ssh1": {"namespaceacl1"}, + "group:ssh2": {"namespaceacl2"}, + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"*:*"}, + }, + }, + SSHs: []headscale.SSH{ + { + Action: "accept", + Sources: []string{"group:ssh1"}, + Destinations: []string{"group:ssh1"}, + Users: []string{"ssh-it-user"}, + }, + { + Action: "accept", + Sources: []string{"group:ssh2"}, + Destinations: []string{"group:ssh2"}, + Users: []string{"ssh-it-user"}, + }, + }, + }, + ), + hsic.WithTestName("sshtwonamespaceaclblock"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_EXPERIMENTAL_FEATURE_SSH": "1", + }), + ) + if err != nil { + t.Errorf("failed to create headscale environment: %s", err) + } + + ssh1Clients, err := scenario.ListTailscaleClients("namespaceacl1") + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + ssh2Clients, err := scenario.ListTailscaleClients("namespaceacl2") + if err != nil { + t.Errorf("failed to get clients: %s", err) + } + + err = scenario.WaitForTailscaleSync() + if err != nil { + t.Errorf("failed wait for tailscale clients to be in sync: %s", err) + } + + _, err = scenario.ListTailscaleClientsFQDNs() + if err != nil { + t.Errorf("failed to get FQDNs: %s", err) + } + + // TODO(kradalby,evenh): ACLs do currently not cover reject + // cases properly, and currently will accept all incomming connections + // as long as a rule is present. + // + // for _, client := range ssh1Clients { + // for _, peer := range ssh2Clients { + // if client.Hostname() == peer.Hostname() { + // continue + // } + // + // assertSSHPermissionDenied(t, client, peer) + // } + // } + // + // for _, client := range ssh2Clients { + // for _, peer := range ssh1Clients { + // if client.Hostname() == peer.Hostname() { + // continue + // } + // + // assertSSHPermissionDenied(t, client, peer) + // } + // } + + for _, client := range ssh1Clients { + for _, peer := range ssh1Clients { + if client.Hostname() == peer.Hostname() { + continue + } + + assertSSHHostname(t, client, peer) + } + } + + for _, client := range ssh2Clients { + for _, peer := range ssh2Clients { + if client.Hostname() == peer.Hostname() { + continue + } + + assertSSHHostname(t, client, peer) + } + } + + err = scenario.Shutdown() + if err != nil { + t.Errorf("failed to tear down scenario: %s", err) + } +} + +func doSSH(t *testing.T, client TailscaleClient, peer TailscaleClient) (string, string, error) { + t.Helper() + + peerFQDN, _ := peer.FQDN() + + command := []string{ + "ssh", "-o StrictHostKeyChecking=no", "-o ConnectTimeout=1", + fmt.Sprintf("%s@%s", "ssh-it-user", peerFQDN), + "'hostname'", + } + + return retry(10, 1*time.Second, func() (string, string, error) { + return client.Execute(command) + }) +} + +func assertSSHHostname(t *testing.T, client TailscaleClient, peer TailscaleClient) { + t.Helper() + + result, _, err := doSSH(t, client, peer) + assert.NoError(t, err) + + assert.Contains(t, peer.ID(), strings.ReplaceAll(result, "\n", "")) +} + +func assertSSHPermissionDenied(t *testing.T, client TailscaleClient, peer TailscaleClient) { + t.Helper() + + result, stderr, err := doSSH(t, client, peer) + assert.Error(t, err) + + assert.Empty(t, result) + + assert.Contains(t, stderr, "Permission denied (tailscale)") +} + +func assertSSHTimeout(t *testing.T, client TailscaleClient, peer TailscaleClient) { + t.Helper() + + result, stderr, err := doSSH(t, client, peer) + assert.NoError(t, err) + + assert.Empty(t, result) + + assert.Contains(t, stderr, "Connection timed out") +} diff --git a/integration/tailscale.go b/integration/tailscale.go index 6b51193a..b69b217a 100644 --- a/integration/tailscale.go +++ b/integration/tailscale.go @@ -7,6 +7,7 @@ import ( "tailscale.com/ipn/ipnstate" ) +//nolint type TailscaleClient interface { Hostname() string Shutdown() error @@ -20,4 +21,5 @@ type TailscaleClient interface { WaitForReady() error WaitForPeers(expected int) error Ping(hostnameOrIP string) error + ID() string } diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index d79f7ba6..d656b1c0 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -47,6 +47,7 @@ type TailscaleInContainer struct { // optional config headscaleCert []byte headscaleHostname string + withSSH bool } type Option = func(c *TailscaleInContainer) @@ -83,6 +84,12 @@ func WithHeadscaleName(hsName string) Option { } } +func WithSSH() Option { + return func(tsic *TailscaleInContainer) { + tsic.withSSH = true + } +} + func New( pool *dockertest.Pool, version string, @@ -176,6 +183,10 @@ func (t *TailscaleInContainer) Version() string { return t.version } +func (t *TailscaleInContainer) ID() string { + return t.container.Container.ID +} + func (t *TailscaleInContainer) Execute( command []string, ) (string, string, error) { @@ -215,6 +226,10 @@ func (t *TailscaleInContainer) Up( t.hostname, } + if t.withSSH { + command = append(command, "--ssh") + } + if _, _, err := t.Execute(command); err != nil { return fmt.Errorf("failed to join tailscale client: %w", err) } diff --git a/machine.go b/machine.go index 8826a654..9be7204e 100644 --- a/machine.go +++ b/machine.go @@ -745,7 +745,11 @@ func (h *Headscale) toNode( KeepAlive: true, MachineAuthorized: !machine.isExpired(), - Capabilities: []string{tailcfg.CapabilityFileSharing}, + Capabilities: []string{ + tailcfg.CapabilityFileSharing, + tailcfg.CapabilityAdmin, + tailcfg.CapabilitySSH, + }, } return &node, nil