From f72de09cd98f64000674298878b99879614f2611 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kristoffer@tailscale.com>
Date: Sat, 19 Nov 2022 12:41:12 +0100
Subject: [PATCH] Add negative tests

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
---
 integration/ssh_test.go | 375 +++++++++++++++++++++++++++++++++-------
 1 file changed, 313 insertions(+), 62 deletions(-)

diff --git a/integration/ssh_test.go b/integration/ssh_test.go
index 0fbebdca..6520e994 100644
--- a/integration/ssh_test.go
+++ b/integration/ssh_test.go
@@ -2,27 +2,35 @@ 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, error)) (string, error) {
+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++ {
-		var result string
-		result, err = doWork()
+		tempResult, tempStderr, err := doWork()
+
+		result += tempResult
+		stderr += tempStderr
+
 		if err == nil {
-			return result, nil
+			return result, stderr, nil
 		}
 		time.Sleep(sleepInterval)
 	}
 
-	return "", err
+	return result, stderr, err
 }
 
 func TestSSHOneNamespaceAllToAll(t *testing.T) {
@@ -81,26 +89,16 @@ func TestSSHOneNamespaceAllToAll(t *testing.T) {
 		t.Errorf("failed to get FQDNs: %s", err)
 	}
 
-	success := 0
-
 	for _, client := range allClients {
 		for _, peer := range allClients {
 			if client.Hostname() == peer.Hostname() {
 				continue
 			}
 
-			if doSSH(t, client, peer) {
-				success++
-			}
+			assertSSHHostname(t, client, peer)
 		}
 	}
 
-	t.Logf(
-		"%d successful pings out of %d",
-		success,
-		(len(allClients)*len(allClients))-len(allClients),
-	)
-
 	err = scenario.Shutdown()
 	if err != nil {
 		t.Errorf("failed to tear down scenario: %s", err)
@@ -169,14 +167,10 @@ func TestSSHMultipleNamespacesAllToAll(t *testing.T) {
 		t.Errorf("failed to get FQDNs: %s", err)
 	}
 
-	success := 0
-
 	testInterNamespaceSSH := func(sourceClients []TailscaleClient, targetClients []TailscaleClient) {
 		for _, client := range sourceClients {
 			for _, peer := range targetClients {
-				if doSSH(t, client, peer) {
-					success++
-				}
+				assertSSHHostname(t, client, peer)
 			}
 		}
 	}
@@ -184,11 +178,71 @@ func TestSSHMultipleNamespacesAllToAll(t *testing.T) {
 	testInterNamespaceSSH(nsOneClients, nsTwoClients)
 	testInterNamespaceSSH(nsTwoClients, nsOneClients)
 
-	t.Logf(
-		"%d successful pings out of %d",
-		success,
-		((len(nsOneClients)*len(nsOneClients))-len(nsOneClients))*2,
+	err = scenario.Shutdown()
+	if err != nil {
+		t.Errorf("failed to tear down scenario: %s", err)
+	}
+}
+
+func TestSSHNoSSHConfigured(t *testing.T) {
+	IntegrationSkip(t)
+
+	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"),
 	)
+	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 {
@@ -196,45 +250,242 @@ func TestSSHMultipleNamespacesAllToAll(t *testing.T) {
 	}
 }
 
-func doSSH(t *testing.T, client TailscaleClient, peer TailscaleClient) bool {
+func TestSSHIsBlockedInACL(t *testing.T) {
+	IntegrationSkip(t)
+
+	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"),
+	)
+	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)
+
+	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"),
+	)
+	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()
 
-	clientFQDN, _ := client.FQDN()
 	peerFQDN, _ := peer.FQDN()
 
-	success := false
+	command := []string{
+		"ssh", "-o StrictHostKeyChecking=no", "-o ConnectTimeout=1",
+		fmt.Sprintf("%s@%s", "ssh-it-user", peerFQDN),
+		"'hostname'",
+	}
 
-	t.Run(
-		fmt.Sprintf("%s-%s", clientFQDN, peerFQDN),
-		func(t *testing.T) {
-			command := []string{
-				"ssh", "-o StrictHostKeyChecking=no", "-o ConnectTimeout=1",
-				fmt.Sprintf("%s@%s", "ssh-it-user", peerFQDN),
-				"'hostname'",
-			}
-
-			result, err := retry(10, 1*time.Second, func() (string, error) {
-				result, _, err := client.Execute(command)
-
-				return result, err
-			})
-			if err != nil {
-				t.Errorf("failed to execute command over SSH: %s", err)
-			}
-
-			if strings.Contains(peer.ID(), result) {
-				t.Logf(
-					"failed to get correct container ID from %s, expected: %s, got: %s",
-					peer.Hostname(),
-					peer.ID(),
-					result,
-				)
-				t.Fail()
-			} else {
-				success = true
-			}
-		},
-	)
-
-	return success
+	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(), result)
+}
+
+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")
 }