diff --git a/.github/workflows/test-integration-v2-TestACLDevice1CanAccessDevice2.yaml b/.github/workflows/test-integration-v2-TestACLDevice1CanAccessDevice2.yaml new file mode 100644 index 0000000000..d25f774f05 --- /dev/null +++ b/.github/workflows/test-integration-v2-TestACLDevice1CanAccessDevice2.yaml @@ -0,0 +1,57 @@ +# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go +# To regenerate, run "go generate" in cmd/gh-action-integration-generator/ + +name: Integration Test v2 - TestACLDevice1CanAccessDevice2 + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v34 + with: + files: | + *.nix + go.* + **/*.go + integration_test/ + config-example.yaml + + - uses: cachix/install-nix-action@v18 + if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true' + + - name: Run general integration tests + if: steps.changed-files.outputs.any_changed == 'true' + run: | + nix develop --command -- docker run \ + --tty --rm \ + --volume ~/.cache/hs-integration-go:/go \ + --name headscale-test-suite \ + --volume $PWD:$PWD -w $PWD/integration \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume $PWD/control_logs:/tmp/control \ + golang:1 \ + go test ./... \ + -tags ts2019 \ + -failfast \ + -timeout 120m \ + -parallel 1 \ + -run "^TestACLDevice1CanAccessDevice2$" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: logs + path: "control_logs/*.log" diff --git a/.github/workflows/test-integration-v2-TestACLNamedHostsCanReach.yaml b/.github/workflows/test-integration-v2-TestACLNamedHostsCanReach.yaml new file mode 100644 index 0000000000..2d62422832 --- /dev/null +++ b/.github/workflows/test-integration-v2-TestACLNamedHostsCanReach.yaml @@ -0,0 +1,57 @@ +# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go +# To regenerate, run "go generate" in cmd/gh-action-integration-generator/ + +name: Integration Test v2 - TestACLNamedHostsCanReach + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v34 + with: + files: | + *.nix + go.* + **/*.go + integration_test/ + config-example.yaml + + - uses: cachix/install-nix-action@v18 + if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true' + + - name: Run general integration tests + if: steps.changed-files.outputs.any_changed == 'true' + run: | + nix develop --command -- docker run \ + --tty --rm \ + --volume ~/.cache/hs-integration-go:/go \ + --name headscale-test-suite \ + --volume $PWD:$PWD -w $PWD/integration \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume $PWD/control_logs:/tmp/control \ + golang:1 \ + go test ./... \ + -tags ts2019 \ + -failfast \ + -timeout 120m \ + -parallel 1 \ + -run "^TestACLNamedHostsCanReach$" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: logs + path: "control_logs/*.log" diff --git a/.github/workflows/test-integration-v2-TestACLNamedHostsCanReachBySubnet.yaml b/.github/workflows/test-integration-v2-TestACLNamedHostsCanReachBySubnet.yaml new file mode 100644 index 0000000000..039512392f --- /dev/null +++ b/.github/workflows/test-integration-v2-TestACLNamedHostsCanReachBySubnet.yaml @@ -0,0 +1,57 @@ +# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go +# To regenerate, run "go generate" in cmd/gh-action-integration-generator/ + +name: Integration Test v2 - TestACLNamedHostsCanReachBySubnet + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v34 + with: + files: | + *.nix + go.* + **/*.go + integration_test/ + config-example.yaml + + - uses: cachix/install-nix-action@v18 + if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true' + + - name: Run general integration tests + if: steps.changed-files.outputs.any_changed == 'true' + run: | + nix develop --command -- docker run \ + --tty --rm \ + --volume ~/.cache/hs-integration-go:/go \ + --name headscale-test-suite \ + --volume $PWD:$PWD -w $PWD/integration \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume $PWD/control_logs:/tmp/control \ + golang:1 \ + go test ./... \ + -tags ts2019 \ + -failfast \ + -timeout 120m \ + -parallel 1 \ + -run "^TestACLNamedHostsCanReachBySubnet$" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: logs + path: "control_logs/*.log" diff --git a/.github/workflows/test-integration-v2-TestACLNamedHostsCanReachV6.yaml b/.github/workflows/test-integration-v2-TestACLNamedHostsCanReachV6.yaml new file mode 100644 index 0000000000..3b2fcfd2ea --- /dev/null +++ b/.github/workflows/test-integration-v2-TestACLNamedHostsCanReachV6.yaml @@ -0,0 +1,57 @@ +# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go +# To regenerate, run "go generate" in cmd/gh-action-integration-generator/ + +name: Integration Test v2 - TestACLNamedHostsCanReachV6 + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v34 + with: + files: | + *.nix + go.* + **/*.go + integration_test/ + config-example.yaml + + - uses: cachix/install-nix-action@v18 + if: ${{ env.ACT }} || steps.changed-files.outputs.any_changed == 'true' + + - name: Run general integration tests + if: steps.changed-files.outputs.any_changed == 'true' + run: | + nix develop --command -- docker run \ + --tty --rm \ + --volume ~/.cache/hs-integration-go:/go \ + --name headscale-test-suite \ + --volume $PWD:$PWD -w $PWD/integration \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume $PWD/control_logs:/tmp/control \ + golang:1 \ + go test ./... \ + -tags ts2019 \ + -failfast \ + -timeout 120m \ + -parallel 1 \ + -run "^TestACLNamedHostsCanReachV6$" + + - uses: actions/upload-artifact@v3 + if: always() && steps.changed-files.outputs.any_changed == 'true' + with: + name: logs + path: "control_logs/*.log" diff --git a/Makefile b/Makefile index 2582c1a591..fbf4b22585 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ test_integration_cli: -v ~/.cache/hs-integration-go:/go \ -v $$PWD:$$PWD -w $$PWD \ -v /var/run/docker.sock:/var/run/docker.sock golang:1 \ - go test $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationCLI ./... + go run gotest.tools/gotestsum@latest -- $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationCLI ./... test_integration_derp: docker network rm $$(docker network ls --filter name=headscale --quiet) || true @@ -46,7 +46,7 @@ test_integration_derp: -v ~/.cache/hs-integration-go:/go \ -v $$PWD:$$PWD -w $$PWD \ -v /var/run/docker.sock:/var/run/docker.sock golang:1 \ - go test $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationDERP ./... + go run gotest.tools/gotestsum@latest -- $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationDERP ./... test_integration_v2_general: docker run \ @@ -56,13 +56,7 @@ test_integration_v2_general: -v $$PWD:$$PWD -w $$PWD/integration \ -v /var/run/docker.sock:/var/run/docker.sock \ golang:1 \ - go test $(TAGS) -failfast ./... -timeout 120m -parallel 8 - -coverprofile_func: - go tool cover -func=coverage.out - -coverprofile_html: - go tool cover -html=coverage.out + go run gotest.tools/gotestsum@latest -- $(TAGS) -failfast ./... -timeout 120m -parallel 8 lint: golangci-lint run --fix --timeout 10m @@ -80,11 +74,4 @@ compress: build generate: rm -rf gen - go run github.com/bufbuild/buf/cmd/buf generate proto - -install-protobuf-plugins: - go install \ - github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \ - github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \ - google.golang.org/protobuf/cmd/protoc-gen-go \ - google.golang.org/grpc/cmd/protoc-gen-go-grpc + buf generate proto diff --git a/acls.go b/acls.go index 64f18fe9af..f30e46ec7c 100644 --- a/acls.go +++ b/acls.go @@ -407,15 +407,40 @@ func generateACLPolicyDest( needsWildcard bool, stripEmaildomain bool, ) ([]tailcfg.NetPortRange, error) { - tokens := strings.Split(dest, ":") + var tokens []string + + log.Trace().Str("destination", dest).Msg("generating policy destination") + + // Check if there is a IPv4/6:Port combination, IPv6 has more than + // three ":". + tokens = strings.Split(dest, ":") if len(tokens) < expectedTokenItems || len(tokens) > 3 { - return nil, errInvalidPortFormat + port := tokens[len(tokens)-1] + + maybeIPv6Str := strings.TrimSuffix(dest, ":"+port) + log.Trace().Str("maybeIPv6Str", maybeIPv6Str).Msg("") + + if maybeIPv6, err := netip.ParseAddr(maybeIPv6Str); err != nil && !maybeIPv6.Is6() { + log.Trace().Err(err).Msg("trying to parse as IPv6") + + return nil, fmt.Errorf( + "failed to parse destination, tokens %v: %w", + tokens, + errInvalidPortFormat, + ) + } else { + tokens = []string{maybeIPv6Str, port} + } } + log.Trace().Strs("tokens", tokens).Msg("generating policy destination") + var alias string // We can have here stuff like: // git-server:* // 192.168.1.0/24:22 + // fd7a:115c:a1e0::2:22 + // fd7a:115c:a1e0::2/128:22 // tag:montreal-webserver:80,443 // tag:api-server:443 // example-host-1:* @@ -666,6 +691,7 @@ func expandPorts(portsStr string, needsWildcard bool) (*[]tailcfg.PortRange, err ports := []tailcfg.PortRange{} for _, portStr := range strings.Split(portsStr, ",") { + log.Trace().Msgf("parsing portstring: %s", portStr) rang := strings.Split(portStr, "-") switch len(rang) { case 1: diff --git a/integration/acl_test.go b/integration/acl_test.go index 42f9b94cd7..f8aeceb13c 100644 --- a/integration/acl_test.go +++ b/integration/acl_test.go @@ -12,16 +12,14 @@ import ( "github.com/stretchr/testify/assert" ) -const numberOfTestClients = 2 - -func aclScenario(t *testing.T, policy headscale.ACLPolicy) *Scenario { +func aclScenario(t *testing.T, policy *headscale.ACLPolicy, clientsPerUser int) *Scenario { t.Helper() scenario, err := NewScenario() assert.NoError(t, err) spec := map[string]int{ - "user1": numberOfTestClients, - "user2": numberOfTestClients, + "user1": clientsPerUser, + "user2": clientsPerUser, } err = scenario.CreateHeadscaleEnv(spec, @@ -29,18 +27,15 @@ func aclScenario(t *testing.T, policy headscale.ACLPolicy) *Scenario { tsic.WithDockerEntrypoint([]string{ "/bin/bash", "-c", - "/bin/sleep 3 ; update-ca-certificates ; python3 -m http.server 80 & tailscaled --tun=tsdev", + "/bin/sleep 3 ; update-ca-certificates ; python3 -m http.server --bind :: 80 & tailscaled --tun=tsdev", }), tsic.WithDockerWorkdir("/"), }, - hsic.WithACLPolicy(&policy), + hsic.WithACLPolicy(policy), hsic.WithTestName("acl"), ) assert.NoError(t, err) - // allClients, err := scenario.ListTailscaleClients() - // assert.NoError(t, err) - err = scenario.WaitForTailscaleSync() assert.NoError(t, err) @@ -230,7 +225,7 @@ func TestACLAllowUser80Dst(t *testing.T) { IntegrationSkip(t) scenario := aclScenario(t, - headscale.ACLPolicy{ + &headscale.ACLPolicy{ ACLs: []headscale.ACL{ { Action: "accept", @@ -239,6 +234,7 @@ func TestACLAllowUser80Dst(t *testing.T) { }, }, }, + 1, ) user1Clients, err := scenario.ListTailscaleClients("user1") @@ -285,7 +281,7 @@ func TestACLDenyAllPort80(t *testing.T) { IntegrationSkip(t) scenario := aclScenario(t, - headscale.ACLPolicy{ + &headscale.ACLPolicy{ Groups: map[string][]string{ "group:integration-acl-test": {"user1", "user2"}, }, @@ -297,6 +293,7 @@ func TestACLDenyAllPort80(t *testing.T) { }, }, }, + 4, ) allClients, err := scenario.ListTailscaleClients() @@ -333,7 +330,7 @@ func TestACLAllowUserDst(t *testing.T) { IntegrationSkip(t) scenario := aclScenario(t, - headscale.ACLPolicy{ + &headscale.ACLPolicy{ ACLs: []headscale.ACL{ { Action: "accept", @@ -342,6 +339,7 @@ func TestACLAllowUserDst(t *testing.T) { }, }, }, + 2, ) user1Clients, err := scenario.ListTailscaleClients("user1") @@ -390,7 +388,7 @@ func TestACLAllowStarDst(t *testing.T) { IntegrationSkip(t) scenario := aclScenario(t, - headscale.ACLPolicy{ + &headscale.ACLPolicy{ ACLs: []headscale.ACL{ { Action: "accept", @@ -399,6 +397,7 @@ func TestACLAllowStarDst(t *testing.T) { }, }, }, + 2, ) user1Clients, err := scenario.ListTailscaleClients("user1") @@ -441,10 +440,73 @@ func TestACLAllowStarDst(t *testing.T) { assert.NoError(t, err) } +// TestACLNamedHostsCanReachBySubnet is the same as +// TestACLNamedHostsCanReach, but it tests if we expand a +// full CIDR correctly. All routes should work. +func TestACLNamedHostsCanReachBySubnet(t *testing.T) { + IntegrationSkip(t) + + scenario := aclScenario(t, + &headscale.ACLPolicy{ + Hosts: headscale.Hosts{ + "all": netip.MustParsePrefix("100.64.0.0/24"), + }, + ACLs: []headscale.ACL{ + // Everyone can curl test3 + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"all:*"}, + }, + }, + }, + 3, + ) + + user1Clients, err := scenario.ListTailscaleClients("user1") + assert.NoError(t, err) + + user2Clients, err := scenario.ListTailscaleClients("user2") + assert.NoError(t, err) + + // Test that user1 can visit all user2 + for _, client := range user1Clients { + for _, peer := range user2Clients { + fqdn, err := peer.FQDN() + assert.NoError(t, err) + + url := fmt.Sprintf("http://%s/etc/hostname", fqdn) + t.Logf("url from %s to %s", client.Hostname(), url) + + result, err := client.Curl(url) + assert.Len(t, result, 13) + assert.NoError(t, err) + } + } + + // Test that user2 can visit all user1 + for _, client := range user2Clients { + for _, peer := range user1Clients { + fqdn, err := peer.FQDN() + assert.NoError(t, err) + + url := fmt.Sprintf("http://%s/etc/hostname", fqdn) + t.Logf("url from %s to %s", client.Hostname(), url) + + result, err := client.Curl(url) + assert.Len(t, result, 13) + assert.NoError(t, err) + } + } + + err = scenario.Shutdown() + assert.NoError(t, err) +} + // This test aims to cover cases where individual hosts are allowed and denied // access based on their assigned hostname // https://github.com/juanfont/headscale/issues/941 - +// // ACL = [{ // "DstPorts": [{ // "Bits": null, @@ -476,178 +538,414 @@ func TestACLAllowStarDst(t *testing.T) { // "100.64.0.2/32": {} // } // } +// +// https://github.com/juanfont/headscale/issues/941 +// Additionally verify ipv6 behaviour, part of +// https://github.com/juanfont/headscale/issues/809 func TestACLNamedHostsCanReach(t *testing.T) { IntegrationSkip(t) - scenario := aclScenario(t, - headscale.ACLPolicy{ - Hosts: headscale.Hosts{ - "test1": netip.MustParsePrefix("100.64.0.1/32"), - "test2": netip.MustParsePrefix("100.64.0.2/32"), - "test3": netip.MustParsePrefix("100.64.0.3/32"), + tests := map[string]struct { + policy headscale.ACLPolicy + }{ + "ipv4": { + policy: headscale.ACLPolicy{ + Hosts: headscale.Hosts{ + "test1": netip.MustParsePrefix("100.64.0.1/32"), + "test2": netip.MustParsePrefix("100.64.0.2/32"), + "test3": netip.MustParsePrefix("100.64.0.3/32"), + }, + ACLs: []headscale.ACL{ + // Everyone can curl test3 + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"test3:*"}, + }, + // test1 can curl test2 + { + Action: "accept", + Sources: []string{"test1"}, + Destinations: []string{"test2:*"}, + }, + }, }, - ACLs: []headscale.ACL{ - // Everyone can curl test3 - { - Action: "accept", - Sources: []string{"*"}, - Destinations: []string{"test3:*"}, + }, + "ipv6": { + policy: headscale.ACLPolicy{ + Hosts: headscale.Hosts{ + "test1": netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + "test2": netip.MustParsePrefix("fd7a:115c:a1e0::2/128"), + "test3": netip.MustParsePrefix("fd7a:115c:a1e0::3/128"), }, - // test1 can curl test2 - { - Action: "accept", - Sources: []string{"test1"}, - Destinations: []string{"test2:*"}, + ACLs: []headscale.ACL{ + // Everyone can curl test3 + { + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{"test3:*"}, + }, + // test1 can curl test2 + { + Action: "accept", + Sources: []string{"test1"}, + Destinations: []string{"test2:*"}, + }, }, }, }, - ) + } - // Since user/users dont matter here, we basically expect that some clients - // will be assigned these ips and that we can pick them up for our own use. - test1ip := netip.MustParseAddr("100.64.0.1") - test1, err := scenario.FindTailscaleClientByIP(test1ip) - assert.NoError(t, err) + for name, testCase := range tests { + t.Run(name, func(t *testing.T) { + scenario := aclScenario(t, + &testCase.policy, + 2, + ) - test1fqdn, err := test1.FQDN() - assert.NoError(t, err) - test1ipURL := fmt.Sprintf("http://%s/etc/hostname", test1ip.String()) - test1fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test1fqdn) + // Since user/users dont matter here, we basically expect that some clients + // will be assigned these ips and that we can pick them up for our own use. + test1ip4 := netip.MustParseAddr("100.64.0.1") + test1ip6 := netip.MustParseAddr("fd7a:115c:a1e0::1") + test1, err := scenario.FindTailscaleClientByIP(test1ip6) + assert.NoError(t, err) - test2ip := netip.MustParseAddr("100.64.0.2") - test2, err := scenario.FindTailscaleClientByIP(test2ip) - assert.NoError(t, err) + test1fqdn, err := test1.FQDN() + assert.NoError(t, err) + test1ip4URL := fmt.Sprintf("http://%s/etc/hostname", test1ip4.String()) + test1ip6URL := fmt.Sprintf("http://[%s]/etc/hostname", test1ip6.String()) + test1fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test1fqdn) - test2fqdn, err := test2.FQDN() - assert.NoError(t, err) - test2ipURL := fmt.Sprintf("http://%s/etc/hostname", test2ip.String()) - test2fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test2fqdn) + test2ip4 := netip.MustParseAddr("100.64.0.2") + test2ip6 := netip.MustParseAddr("fd7a:115c:a1e0::2") + test2, err := scenario.FindTailscaleClientByIP(test2ip6) + assert.NoError(t, err) - test3ip := netip.MustParseAddr("100.64.0.3") - test3, err := scenario.FindTailscaleClientByIP(test3ip) - assert.NoError(t, err) + test2fqdn, err := test2.FQDN() + assert.NoError(t, err) + test2ip4URL := fmt.Sprintf("http://%s/etc/hostname", test2ip4.String()) + test2ip6URL := fmt.Sprintf("http://[%s]/etc/hostname", test2ip6.String()) + test2fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test2fqdn) - test3fqdn, err := test3.FQDN() - assert.NoError(t, err) - test3ipURL := fmt.Sprintf("http://%s/etc/hostname", test3ip.String()) - test3fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test3fqdn) + test3ip4 := netip.MustParseAddr("100.64.0.3") + test3ip6 := netip.MustParseAddr("fd7a:115c:a1e0::3") + test3, err := scenario.FindTailscaleClientByIP(test3ip6) + assert.NoError(t, err) - // test1 can query test3 - result, err := test1.Curl(test3ipURL) - assert.Len(t, result, 13) - assert.NoError(t, err) + test3fqdn, err := test3.FQDN() + assert.NoError(t, err) + test3ip4URL := fmt.Sprintf("http://%s/etc/hostname", test3ip4.String()) + test3ip6URL := fmt.Sprintf("http://[%s]/etc/hostname", test3ip6.String()) + test3fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test3fqdn) + + // test1 can query test3 + result, err := test1.Curl(test3ip4URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test3 with URL %s, expected hostname of 13 chars, got %s", + test3ip4URL, + result, + ) + assert.NoError(t, err) - result, err = test1.Curl(test3fqdnURL) - assert.Len(t, result, 13) - assert.NoError(t, err) + result, err = test1.Curl(test3ip6URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test3 with URL %s, expected hostname of 13 chars, got %s", + test3ip6URL, + result, + ) + assert.NoError(t, err) - // test2 can query test3 - result, err = test2.Curl(test3ipURL) - assert.Len(t, result, 13) - assert.NoError(t, err) + result, err = test1.Curl(test3fqdnURL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test3 with URL %s, expected hostname of 13 chars, got %s", + test3fqdnURL, + result, + ) + assert.NoError(t, err) - result, err = test2.Curl(test3fqdnURL) - assert.Len(t, result, 13) - assert.NoError(t, err) + // test2 can query test3 + result, err = test2.Curl(test3ip4URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test3 with URL %s, expected hostname of 13 chars, got %s", + test3ip4URL, + result, + ) + assert.NoError(t, err) + + result, err = test2.Curl(test3ip6URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test3 with URL %s, expected hostname of 13 chars, got %s", + test3ip6URL, + result, + ) + assert.NoError(t, err) + + result, err = test2.Curl(test3fqdnURL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test3 with URL %s, expected hostname of 13 chars, got %s", + test3fqdnURL, + result, + ) + assert.NoError(t, err) + + // test3 cannot query test1 + result, err = test3.Curl(test1ip4URL) + assert.Empty(t, result) + assert.Error(t, err) - // test3 cannot query test1 - result, err = test3.Curl(test1ipURL) - assert.Empty(t, result) - assert.Error(t, err) + result, err = test3.Curl(test1ip6URL) + assert.Empty(t, result) + assert.Error(t, err) - result, err = test3.Curl(test1fqdnURL) - assert.Empty(t, result) - assert.Error(t, err) + result, err = test3.Curl(test1fqdnURL) + assert.Empty(t, result) + assert.Error(t, err) - // test3 cannot query test2 - result, err = test3.Curl(test2ipURL) - assert.Empty(t, result) - assert.Error(t, err) + // test3 cannot query test2 + result, err = test3.Curl(test2ip4URL) + assert.Empty(t, result) + assert.Error(t, err) - result, err = test3.Curl(test2fqdnURL) - assert.Empty(t, result) - assert.Error(t, err) + result, err = test3.Curl(test2ip6URL) + assert.Empty(t, result) + assert.Error(t, err) - // test1 can query test2 - result, err = test1.Curl(test2ipURL) - assert.Len(t, result, 13) - assert.NoError(t, err) + result, err = test3.Curl(test2fqdnURL) + assert.Empty(t, result) + assert.Error(t, err) - result, err = test1.Curl(test2fqdnURL) - assert.Len(t, result, 13) - assert.NoError(t, err) + // test1 can query test2 + result, err = test1.Curl(test2ip4URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test2 with URL %s, expected hostname of 13 chars, got %s", + test2ip4URL, + result, + ) - // test2 cannot query test1 - result, err = test2.Curl(test1ipURL) - assert.Empty(t, result) - assert.Error(t, err) + assert.NoError(t, err) + result, err = test1.Curl(test2ip6URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test2 with URL %s, expected hostname of 13 chars, got %s", + test2ip6URL, + result, + ) + assert.NoError(t, err) - result, err = test2.Curl(test1fqdnURL) - assert.Empty(t, result) - assert.Error(t, err) + result, err = test1.Curl(test2fqdnURL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test2 with URL %s, expected hostname of 13 chars, got %s", + test2fqdnURL, + result, + ) + assert.NoError(t, err) - err = scenario.Shutdown() - assert.NoError(t, err) + // test2 cannot query test1 + result, err = test2.Curl(test1ip4URL) + assert.Empty(t, result) + assert.Error(t, err) + + result, err = test2.Curl(test1ip6URL) + assert.Empty(t, result) + assert.Error(t, err) + + result, err = test2.Curl(test1fqdnURL) + assert.Empty(t, result) + assert.Error(t, err) + + err = scenario.Shutdown() + assert.NoError(t, err) + }) + } } -// TestACLNamedHostsCanReachBySubnet is the same as -// TestACLNamedHostsCanReach, but it tests if we expand a -// full CIDR correctly. All routes should work. -func TestACLNamedHostsCanReachBySubnet(t *testing.T) { +// TestACLDevice1CanAccessDevice2 is a table driven test that aims to test +// the various ways to achieve a connection between device1 and device2 where +// device1 can access device2, but not the other way around. This can be +// viewed as one of the most important tests here as it covers most of the +// syntax that can be used. +// +// Before adding new taste cases, consider if it can be reduced to a case +// in this function. +func TestACLDevice1CanAccessDevice2(t *testing.T) { IntegrationSkip(t) - scenario := aclScenario(t, - headscale.ACLPolicy{ - Hosts: headscale.Hosts{ - "all": netip.MustParsePrefix("100.64.0.0/24"), + tests := map[string]struct { + policy headscale.ACLPolicy + }{ + "ipv4": { + policy: headscale.ACLPolicy{ + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"100.64.0.1"}, + Destinations: []string{"100.64.0.2:*"}, + }, + }, }, - ACLs: []headscale.ACL{ - // Everyone can curl test3 - { - Action: "accept", - Sources: []string{"*"}, - Destinations: []string{"all:*"}, + }, + "ipv6": { + policy: headscale.ACLPolicy{ + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"fd7a:115c:a1e0::1"}, + Destinations: []string{"fd7a:115c:a1e0::2:*"}, + }, }, }, }, - ) + "hostv4cidr": { + policy: headscale.ACLPolicy{ + Hosts: headscale.Hosts{ + "test1": netip.MustParsePrefix("100.64.0.1/32"), + "test2": netip.MustParsePrefix("100.64.0.2/32"), + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"test1"}, + Destinations: []string{"test2:*"}, + }, + }, + }, + }, + "hostv6cidr": { + policy: headscale.ACLPolicy{ + Hosts: headscale.Hosts{ + "test1": netip.MustParsePrefix("fd7a:115c:a1e0::1/128"), + "test2": netip.MustParsePrefix("fd7a:115c:a1e0::2/128"), + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"test1"}, + Destinations: []string{"test2:*"}, + }, + }, + }, + }, + "group": { + policy: headscale.ACLPolicy{ + Groups: map[string][]string{ + "group:one": {"user1"}, + "group:two": {"user2"}, + }, + ACLs: []headscale.ACL{ + { + Action: "accept", + Sources: []string{"group:one"}, + Destinations: []string{"group:two:*"}, + }, + }, + }, + }, + // TODO(kradalby): Add similar tests for Tags, might need support + // in the scenario function when we create or join the clients. + } - user1Clients, err := scenario.ListTailscaleClients("user1") - assert.NoError(t, err) + for name, testCase := range tests { + t.Run(name, func(t *testing.T) { + scenario := aclScenario(t, &testCase.policy, 1) - user2Clients, err := scenario.ListTailscaleClients("user2") - assert.NoError(t, err) + test1ip := netip.MustParseAddr("100.64.0.1") + test1ip6 := netip.MustParseAddr("fd7a:115c:a1e0::1") + test1, err := scenario.FindTailscaleClientByIP(test1ip) + assert.NotNil(t, test1) + assert.NoError(t, err) - // Test that user1 can visit all user2 - for _, client := range user1Clients { - for _, peer := range user2Clients { - fqdn, err := peer.FQDN() + test1fqdn, err := test1.FQDN() + assert.NoError(t, err) + test1ipURL := fmt.Sprintf("http://%s/etc/hostname", test1ip.String()) + test1ip6URL := fmt.Sprintf("http://[%s]/etc/hostname", test1ip6.String()) + test1fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test1fqdn) + + test2ip := netip.MustParseAddr("100.64.0.2") + test2ip6 := netip.MustParseAddr("fd7a:115c:a1e0::2") + test2, err := scenario.FindTailscaleClientByIP(test2ip) + assert.NotNil(t, test2) assert.NoError(t, err) - url := fmt.Sprintf("http://%s/etc/hostname", fqdn) - t.Logf("url from %s to %s", client.Hostname(), url) + test2fqdn, err := test2.FQDN() + assert.NoError(t, err) + test2ipURL := fmt.Sprintf("http://%s/etc/hostname", test2ip.String()) + test2ip6URL := fmt.Sprintf("http://[%s]/etc/hostname", test2ip6.String()) + test2fqdnURL := fmt.Sprintf("http://%s/etc/hostname", test2fqdn) + + // test1 can query test2 + result, err := test1.Curl(test2ipURL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test with URL %s, expected hostname of 13 chars, got %s", + test2ipURL, + result, + ) + assert.NoError(t, err) - result, err := client.Curl(url) - assert.Len(t, result, 13) + result, err = test1.Curl(test2ip6URL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test with URL %s, expected hostname of 13 chars, got %s", + test2ip6URL, + result, + ) assert.NoError(t, err) - } - } - // Test that user2 can visit all user1 - for _, client := range user2Clients { - for _, peer := range user1Clients { - fqdn, err := peer.FQDN() + result, err = test1.Curl(test2fqdnURL) + assert.Lenf( + t, + result, + 13, + "failed to connect from test1 to test with URL %s, expected hostname of 13 chars, got %s", + test2fqdnURL, + result, + ) assert.NoError(t, err) - url := fmt.Sprintf("http://%s/etc/hostname", fqdn) - t.Logf("url from %s to %s", client.Hostname(), url) + result, err = test2.Curl(test1ipURL) + assert.Empty(t, result) + assert.Error(t, err) - result, err := client.Curl(url) - assert.Len(t, result, 13) + result, err = test2.Curl(test1ip6URL) + assert.Empty(t, result) + assert.Error(t, err) + + result, err = test2.Curl(test1fqdnURL) + assert.Empty(t, result) + assert.Error(t, err) + + err = scenario.Shutdown() assert.NoError(t, err) - } + }) } - - err = scenario.Shutdown() - assert.NoError(t, err) } diff --git a/integration/utils.go b/integration/utils.go index 40cf103c0b..ae6d578f59 100644 --- a/integration/utils.go +++ b/integration/utils.go @@ -46,3 +46,35 @@ func pingAllHelper(t *testing.T, clients []TailscaleClient, addrs []string) int // // return failures // } + +// // findPeerByIP takes an IP and a map of peers from status.Peer, and returns a *ipnstate.PeerStatus +// // if there is a peer with the given IP. If no peer is found, nil is returned. +// func findPeerByIP( +// ip netip.Addr, +// peers map[key.NodePublic]*ipnstate.PeerStatus, +// ) *ipnstate.PeerStatus { +// for _, peer := range peers { +// for _, peerIP := range peer.TailscaleIPs { +// if ip == peerIP { +// return peer +// } +// } +// } +// +// return nil +// } +// +// // findPeerByHostname takes a hostname and a map of peers from status.Peer, and returns a *ipnstate.PeerStatus +// // if there is a peer with the given hostname. If no peer is found, nil is returned. +// func findPeerByHostname( +// hostname string, +// peers map[key.NodePublic]*ipnstate.PeerStatus, +// ) *ipnstate.PeerStatus { +// for _, peer := range peers { +// if hostname == peer.HostName { +// return peer +// } +// } +// +// return nil +// }