mirror of
https://github.com/juanfont/headscale.git
synced 2024-11-29 18:33:05 +00:00
Merge branch 'main' into rerouting
This commit is contained in:
commit
70611746cb
17 changed files with 796 additions and 24 deletions
13
CHANGELOG.md
13
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)
|
- 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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
123
acls.go
|
@ -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,
|
||||||
|
|
76
acls_test.go
76
acls_test.go
|
@ -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{
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
519
integration/ssh_test.go
Normal 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")
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue