Merge branch 'main' into rerouting

This commit is contained in:
Juan Font 2022-11-28 16:13:37 +00:00
commit 70611746cb
17 changed files with 796 additions and 24 deletions

View File

@ -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) - 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) - 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) - 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) - 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 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) - Target Tailscale v1.30.0 to build Headscale [#780](https://github.com/juanfont/headscale/pull/780)

View File

@ -4,14 +4,16 @@ ARG TAILSCALE_VERSION=*
ARG TAILSCALE_CHANNEL=stable ARG TAILSCALE_CHANNEL=stable
RUN apt-get update \ 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.gpg | apt-key add - \
&& curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \ && curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
&& apt-get update \ && apt-get update \
&& apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \ && apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \
&& rm -rf /var/lib/apt/lists/* && 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/ 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 RUN update-ca-certificates

View File

@ -1,9 +1,10 @@
FROM golang:latest FROM golang:latest
RUN apt-get update \ 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/* && 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 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/ RUN cp tailscaled /usr/local/bin/
ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/ 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 RUN update-ca-certificates

123
acls.go
View File

@ -10,10 +10,12 @@ 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"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"tailscale.com/envknob"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@ -54,6 +56,8 @@ const (
ProtocolFC = 133 // Fibre Channel 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. // LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules.
func (h *Headscale) LoadACLPolicy(path string) error { func (h *Headscale) LoadACLPolicy(path string) error {
log.Debug(). log.Debug().
@ -120,6 +124,20 @@ 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
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 return nil
} }
@ -187,6 +205,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,

View File

@ -7,6 +7,7 @@ import (
"testing" "testing"
"gopkg.in/check.v1" "gopkg.in/check.v1"
"tailscale.com/envknob"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@ -73,6 +74,81 @@ 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) {
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) { 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{

View File

@ -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{}

View File

@ -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
View File

@ -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]

View File

@ -10,6 +10,7 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1" v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -39,7 +40,7 @@ func TestNamespaceCommand(t *testing.T) {
"namespace2": 0, "namespace2": 0,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clins")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clins"))
assert.NoError(t, err) assert.NoError(t, err)
headscale, err := scenario.Headscale() headscale, err := scenario.Headscale()
@ -120,7 +121,7 @@ func TestPreAuthKeyCommand(t *testing.T) {
namespace: 0, namespace: 0,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipak")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipak"))
assert.NoError(t, err) assert.NoError(t, err)
headscale, err := scenario.Headscale() headscale, err := scenario.Headscale()
@ -260,7 +261,7 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) {
namespace: 0, namespace: 0,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipaknaexp")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipaknaexp"))
assert.NoError(t, err) assert.NoError(t, err)
headscale, err := scenario.Headscale() headscale, err := scenario.Headscale()
@ -325,7 +326,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) {
namespace: 0, namespace: 0,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipakresueeph")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipakresueeph"))
assert.NoError(t, err) assert.NoError(t, err)
headscale, err := scenario.Headscale() headscale, err := scenario.Headscale()

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -24,7 +25,7 @@ func TestPingAllByIP(t *testing.T) {
"namespace2": len(TailscaleVersions), "namespace2": len(TailscaleVersions),
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("pingallbyip")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("pingallbyip"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@ -80,7 +81,7 @@ func TestPingAllByHostname(t *testing.T) {
"namespace4": len(TailscaleVersions) - 1, "namespace4": len(TailscaleVersions) - 1,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("pingallbyname")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("pingallbyname"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@ -148,7 +149,7 @@ func TestTaildrop(t *testing.T) {
"taildrop": len(TailscaleVersions) - 1, "taildrop": len(TailscaleVersions) - 1,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("taildrop")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("taildrop"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@ -276,7 +277,7 @@ func TestResolveMagicDNS(t *testing.T) {
"magicdns2": len(TailscaleVersions) - 1, "magicdns2": len(TailscaleVersions) - 1,
} }
err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("magicdns")) err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("magicdns"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }

View File

@ -79,13 +79,9 @@ func WithTLS() Option {
func WithConfigEnv(configEnv map[string]string) Option { func WithConfigEnv(configEnv map[string]string) Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
env := []string{}
for key, value := range configEnv { 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
} }
} }

View File

@ -229,6 +229,7 @@ func (s *Scenario) CreateTailscaleNodesInNamespace(
namespaceStr string, namespaceStr string,
requestedVersion string, requestedVersion string,
count int, count int,
opts ...tsic.Option,
) error { ) error {
if namespace, ok := s.namespaces[namespaceStr]; ok { if namespace, ok := s.namespaces[namespaceStr]; ok {
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@ -247,6 +248,11 @@ func (s *Scenario) CreateTailscaleNodesInNamespace(
namespace.createWaitGroup.Add(1) namespace.createWaitGroup.Add(1)
opts = append(opts,
tsic.WithHeadscaleTLS(cert),
tsic.WithHeadscaleName(hostname),
)
go func() { go func() {
defer namespace.createWaitGroup.Done() defer namespace.createWaitGroup.Done()
@ -255,8 +261,7 @@ func (s *Scenario) CreateTailscaleNodesInNamespace(
s.pool, s.pool,
version, version,
s.network, s.network,
tsic.WithHeadscaleTLS(cert), opts...,
tsic.WithHeadscaleName(hostname),
) )
if err != nil { if err != nil {
// return fmt.Errorf("failed to add tailscale node: %w", err) // 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 // CreateHeadscaleEnv is a conventient method returning a set up Headcale
// test environment with nodes of all versions, joined to the server with X // test environment with nodes of all versions, joined to the server with X
// namespaces. // 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...) headscale, err := s.Headscale(opts...)
if err != nil { if err != nil {
return err return err
@ -353,7 +362,7 @@ func (s *Scenario) CreateHeadscaleEnv(namespaces map[string]int, opts ...hsic.Op
return err return err
} }
err = s.CreateTailscaleNodesInNamespace(namespaceName, "all", clientCount) err = s.CreateTailscaleNodesInNamespace(namespaceName, "all", clientCount, tsOpts...)
if err != nil { if err != nil {
return err return err
} }

View File

@ -6,7 +6,7 @@ import (
"github.com/juanfont/headscale/integration/dockertestutil" "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. // some Headcsale/Tailscale stuff, but mostly in very simple ways.
func IntegrationSkip(t *testing.T) { func IntegrationSkip(t *testing.T) {

519
integration/ssh_test.go Normal file
View File

@ -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")
}

View File

@ -7,6 +7,7 @@ import (
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
) )
//nolint
type TailscaleClient interface { type TailscaleClient interface {
Hostname() string Hostname() string
Shutdown() error Shutdown() error
@ -20,4 +21,5 @@ type TailscaleClient interface {
WaitForReady() error WaitForReady() error
WaitForPeers(expected int) error WaitForPeers(expected int) error
Ping(hostnameOrIP string) error Ping(hostnameOrIP string) error
ID() string
} }

View File

@ -47,6 +47,7 @@ type TailscaleInContainer struct {
// optional config // optional config
headscaleCert []byte headscaleCert []byte
headscaleHostname string headscaleHostname string
withSSH bool
} }
type Option = func(c *TailscaleInContainer) 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( func New(
pool *dockertest.Pool, pool *dockertest.Pool,
version string, version string,
@ -176,6 +183,10 @@ func (t *TailscaleInContainer) Version() string {
return t.version return t.version
} }
func (t *TailscaleInContainer) ID() string {
return t.container.Container.ID
}
func (t *TailscaleInContainer) Execute( func (t *TailscaleInContainer) Execute(
command []string, command []string,
) (string, string, error) { ) (string, string, error) {
@ -215,6 +226,10 @@ func (t *TailscaleInContainer) Up(
t.hostname, t.hostname,
} }
if t.withSSH {
command = append(command, "--ssh")
}
if _, _, err := t.Execute(command); err != nil { if _, _, err := t.Execute(command); err != nil {
return fmt.Errorf("failed to join tailscale client: %w", err) return fmt.Errorf("failed to join tailscale client: %w", err)
} }

View File

@ -745,7 +745,11 @@ func (h *Headscale) 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