From 5e74ca941423c24b87bd956a70f404d254466ac6 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 16 Apr 2023 12:26:35 +0200 Subject: [PATCH] Fix IPv6 in ACLs (#1339) --- ...ion-v2-TestACLDevice1CanAccessDevice2.yaml | 57 ++ ...egration-v2-TestACLNamedHostsCanReach.yaml | 57 ++ ...-v2-TestACLNamedHostsCanReachBySubnet.yaml | 57 ++ Makefile | 21 +- acls.go | 72 +- acls_test.go | 88 ++- integration/acl_test.go | 626 +++++++++++++----- integration/utils.go | 32 + machine.go | 14 + 9 files changed, 816 insertions(+), 208 deletions(-) create mode 100644 .github/workflows/test-integration-v2-TestACLDevice1CanAccessDevice2.yaml create mode 100644 .github/workflows/test-integration-v2-TestACLNamedHostsCanReach.yaml create mode 100644 .github/workflows/test-integration-v2-TestACLNamedHostsCanReachBySubnet.yaml diff --git a/.github/workflows/test-integration-v2-TestACLDevice1CanAccessDevice2.yaml b/.github/workflows/test-integration-v2-TestACLDevice1CanAccessDevice2.yaml new file mode 100644 index 00000000..d25f774f --- /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 00000000..2d624228 --- /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 00000000..03951239 --- /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/Makefile b/Makefile index 2582c1a5..fbf4b225 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 64f18fe9..2073ee84 100644 --- a/acls.go +++ b/acls.go @@ -13,6 +13,7 @@ import ( "time" "github.com/rs/zerolog/log" + "github.com/samber/lo" "github.com/tailscale/hujson" "go4.org/netipx" "gopkg.in/yaml.v3" @@ -407,15 +408,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:* @@ -508,9 +534,11 @@ func parseProtocol(protocol string) ([]int, bool, error) { // - a group // - a tag // - a host +// - an ip +// - a cidr // and transform these in IPAddresses. func expandAlias( - machines []Machine, + machines Machines, aclPolicy ACLPolicy, alias string, stripEmailDomain bool, @@ -592,19 +620,40 @@ func expandAlias( // if alias is an host if h, ok := aclPolicy.Hosts[alias]; ok { - return []string{h.String()}, nil + log.Trace().Str("host", h.String()).Msg("expandAlias got hosts entry") + + return expandAlias(machines, aclPolicy, h.String(), stripEmailDomain) } // if alias is an IP - ip, err := netip.ParseAddr(alias) - if err == nil { - return []string{ip.String()}, nil + if ip, err := netip.ParseAddr(alias); err == nil { + log.Trace().Str("ip", ip.String()).Msg("expandAlias got ip") + ips := []string{ip.String()} + matches := machines.FilterByIP(ip) + + for _, machine := range matches { + ips = append(ips, machine.IPAddresses.ToStringSlice()...) + } + + return lo.Uniq(ips), nil } - // if alias is an CIDR - cidr, err := netip.ParsePrefix(alias) - if err == nil { - return []string{cidr.String()}, nil + if cidr, err := netip.ParsePrefix(alias); err == nil { + log.Trace().Str("cidr", cidr.String()).Msg("expandAlias got cidr") + val := []string{cidr.String()} + // This is suboptimal and quite expensive, but if we only add the cidr, we will miss all the relevant IPv6 + // addresses for the hosts that belong to tailscale. This doesnt really affect stuff like subnet routers. + for _, machine := range machines { + for _, ip := range machine.IPAddresses { + // log.Trace(). + // Msgf("checking if machine ip (%s) is part of cidr (%s): %v, is single ip cidr (%v), addr: %s", ip.String(), cidr.String(), cidr.Contains(ip), cidr.IsSingleIP(), cidr.Addr().String()) + if cidr.Contains(ip) { + val = append(val, machine.IPAddresses.ToStringSlice()...) + } + } + } + + return lo.Uniq(val), nil } log.Warn().Msgf("No IPs found with the alias %v", alias) @@ -666,6 +715,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/acls_test.go b/acls_test.go index 7d464c71..eaac2a7c 100644 --- a/acls_test.go +++ b/acls_test.go @@ -1026,22 +1026,7 @@ func Test_expandAlias(t *testing.T) { wantErr: false, }, { - name: "private network", - args: args{ - alias: "homeNetwork", - machines: []Machine{}, - aclPolicy: ACLPolicy{ - Hosts: Hosts{ - "homeNetwork": netip.MustParsePrefix("192.168.1.0/24"), - }, - }, - stripEmailDomain: true, - }, - want: []string{"192.168.1.0/24"}, - wantErr: false, - }, - { - name: "simple host by ip", + name: "simple host by ip passed through", args: args{ alias: "10.0.0.1", machines: []Machine{}, @@ -1051,6 +1036,62 @@ func Test_expandAlias(t *testing.T) { want: []string{"10.0.0.1"}, wantErr: false, }, + { + name: "simple host by ipv4 single ipv4", + args: args{ + alias: "10.0.0.1", + machines: []Machine{ + { + IPAddresses: MachineAddresses{ + netip.MustParseAddr("10.0.0.1"), + }, + User: User{Name: "mickael"}, + }, + }, + aclPolicy: ACLPolicy{}, + stripEmailDomain: true, + }, + want: []string{"10.0.0.1"}, + wantErr: false, + }, + { + name: "simple host by ipv4 single dual stack", + args: args{ + alias: "10.0.0.1", + machines: []Machine{ + { + IPAddresses: MachineAddresses{ + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:2222:6273:2222"), + }, + User: User{Name: "mickael"}, + }, + }, + aclPolicy: ACLPolicy{}, + stripEmailDomain: true, + }, + want: []string{"10.0.0.1", "fd7a:115c:a1e0:ab12:4843:2222:6273:2222"}, + wantErr: false, + }, + { + name: "simple host by ipv6 single dual stack", + args: args{ + alias: "fd7a:115c:a1e0:ab12:4843:2222:6273:2222", + machines: []Machine{ + { + IPAddresses: MachineAddresses{ + netip.MustParseAddr("10.0.0.1"), + netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:2222:6273:2222"), + }, + User: User{Name: "mickael"}, + }, + }, + aclPolicy: ACLPolicy{}, + stripEmailDomain: true, + }, + want: []string{"fd7a:115c:a1e0:ab12:4843:2222:6273:2222", "10.0.0.1"}, + wantErr: false, + }, { name: "simple host by hostname alias", args: args{ @@ -1066,6 +1107,21 @@ func Test_expandAlias(t *testing.T) { want: []string{"10.0.0.132/32"}, wantErr: false, }, + { + name: "private network", + args: args{ + alias: "homeNetwork", + machines: []Machine{}, + aclPolicy: ACLPolicy{ + Hosts: Hosts{ + "homeNetwork": netip.MustParsePrefix("192.168.1.0/24"), + }, + }, + stripEmailDomain: true, + }, + want: []string{"192.168.1.0/24"}, + wantErr: false, + }, { name: "simple CIDR", args: args{ diff --git a/integration/acl_test.go b/integration/acl_test.go index 42f9b94c..f8aeceb1 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,155 +440,6 @@ func TestACLAllowStarDst(t *testing.T) { 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, -// "IP": "100.64.0.3/32", -// "Ports": { -// "First": 0, -// "Last": 65535 -// } -// }], -// "SrcIPs": ["*"] -// }, { -// -// "DstPorts": [{ -// "Bits": null, -// "IP": "100.64.0.2/32", -// "Ports": { -// "First": 0, -// "Last": 65535 -// } -// }], -// "SrcIPs": ["100.64.0.1/32"] -// }] -// -// ACL Cache Map= { -// "*": { -// "100.64.0.3/32": {} -// }, -// "100.64.0.1/32": { -// "100.64.0.2/32": {} -// } -// } -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"), - }, - 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) - - 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) - - test2ip := netip.MustParseAddr("100.64.0.2") - test2, err := scenario.FindTailscaleClientByIP(test2ip) - assert.NoError(t, err) - - 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) - - test3ip := netip.MustParseAddr("100.64.0.3") - test3, err := scenario.FindTailscaleClientByIP(test3ip) - assert.NoError(t, err) - - 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) - - // test1 can query test3 - result, err := test1.Curl(test3ipURL) - assert.Len(t, result, 13) - assert.NoError(t, err) - - result, err = test1.Curl(test3fqdnURL) - assert.Len(t, result, 13) - assert.NoError(t, err) - - // test2 can query test3 - result, err = test2.Curl(test3ipURL) - assert.Len(t, result, 13) - assert.NoError(t, err) - - result, err = test2.Curl(test3fqdnURL) - assert.Len(t, result, 13) - assert.NoError(t, err) - - // test3 cannot query test1 - result, err = test3.Curl(test1ipURL) - 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) - - result, err = test3.Curl(test2fqdnURL) - 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 = test1.Curl(test2fqdnURL) - assert.Len(t, result, 13) - assert.NoError(t, err) - - // test2 cannot query test1 - result, err = test2.Curl(test1ipURL) - 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. @@ -597,7 +447,7 @@ func TestACLNamedHostsCanReachBySubnet(t *testing.T) { IntegrationSkip(t) scenario := aclScenario(t, - headscale.ACLPolicy{ + &headscale.ACLPolicy{ Hosts: headscale.Hosts{ "all": netip.MustParsePrefix("100.64.0.0/24"), }, @@ -610,6 +460,7 @@ func TestACLNamedHostsCanReachBySubnet(t *testing.T) { }, }, }, + 3, ) user1Clients, err := scenario.ListTailscaleClients("user1") @@ -651,3 +502,450 @@ func TestACLNamedHostsCanReachBySubnet(t *testing.T) { 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, +// "IP": "100.64.0.3/32", +// "Ports": { +// "First": 0, +// "Last": 65535 +// } +// }], +// "SrcIPs": ["*"] +// }, { +// +// "DstPorts": [{ +// "Bits": null, +// "IP": "100.64.0.2/32", +// "Ports": { +// "First": 0, +// "Last": 65535 +// } +// }], +// "SrcIPs": ["100.64.0.1/32"] +// }] +// +// ACL Cache Map= { +// "*": { +// "100.64.0.3/32": {} +// }, +// "100.64.0.1/32": { +// "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) + + 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:*"}, + }, + }, + }, + }, + "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"), + }, + 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:*"}, + }, + }, + }, + }, + } + + for name, testCase := range tests { + t.Run(name, func(t *testing.T) { + scenario := aclScenario(t, + &testCase.policy, + 2, + ) + + // 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) + + 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) + + test2ip4 := netip.MustParseAddr("100.64.0.2") + test2ip6 := netip.MustParseAddr("fd7a:115c:a1e0::2") + test2, err := scenario.FindTailscaleClientByIP(test2ip6) + 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) + + test3ip4 := netip.MustParseAddr("100.64.0.3") + test3ip6 := netip.MustParseAddr("fd7a:115c:a1e0::3") + test3, err := scenario.FindTailscaleClientByIP(test3ip6) + 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(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 = 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) + + // 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) + + 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) + + // test3 cannot query test2 + result, err = test3.Curl(test2ip4URL) + assert.Empty(t, result) + assert.Error(t, err) + + result, err = test3.Curl(test2ip6URL) + assert.Empty(t, result) + assert.Error(t, err) + + result, err = test3.Curl(test2fqdnURL) + assert.Empty(t, result) + assert.Error(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, + ) + + 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 = 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) + + // 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) + }) + } +} + +// 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) + + 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:*"}, + }, + }, + }, + }, + "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. + } + + for name, testCase := range tests { + t.Run(name, func(t *testing.T) { + scenario := aclScenario(t, &testCase.policy, 1) + + 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) + + 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) + + 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 = 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) + + 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) + + result, err = test2.Curl(test1ipURL) + 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) + }) + } +} diff --git a/integration/utils.go b/integration/utils.go index 40cf103c..ae6d578f 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 +// } diff --git a/machine.go b/machine.go index 71217ab5..6dfa9501 100644 --- a/machine.go +++ b/machine.go @@ -1267,3 +1267,17 @@ func (h *Headscale) GenerateGivenName(machineKey string, suppliedName string) (s return givenName, nil } + +func (machines Machines) FilterByIP(ip netip.Addr) Machines { + found := make(Machines, 0) + + for _, machine := range machines { + for _, mIP := range machine.IPAddresses { + if ip == mIP { + found = append(found, machine) + } + } + } + + return found +}