Fix IPv6 in ACLs (#1339)

This commit is contained in:
Kristoffer Dalby 2023-04-16 12:26:35 +02:00 committed by GitHub
parent 9836b097a4
commit 5e74ca9414
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 816 additions and 208 deletions

View file

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

View file

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

View file

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

View file

@ -36,7 +36,7 @@ test_integration_cli:
-v ~/.cache/hs-integration-go:/go \ -v ~/.cache/hs-integration-go:/go \
-v $$PWD:$$PWD -w $$PWD \ -v $$PWD:$$PWD -w $$PWD \
-v /var/run/docker.sock:/var/run/docker.sock golang:1 \ -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: test_integration_derp:
docker network rm $$(docker network ls --filter name=headscale --quiet) || true 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 ~/.cache/hs-integration-go:/go \
-v $$PWD:$$PWD -w $$PWD \ -v $$PWD:$$PWD -w $$PWD \
-v /var/run/docker.sock:/var/run/docker.sock golang:1 \ -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: test_integration_v2_general:
docker run \ docker run \
@ -56,13 +56,7 @@ test_integration_v2_general:
-v $$PWD:$$PWD -w $$PWD/integration \ -v $$PWD:$$PWD -w $$PWD/integration \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
golang:1 \ golang:1 \
go test $(TAGS) -failfast ./... -timeout 120m -parallel 8 go run gotest.tools/gotestsum@latest -- $(TAGS) -failfast ./... -timeout 120m -parallel 8
coverprofile_func:
go tool cover -func=coverage.out
coverprofile_html:
go tool cover -html=coverage.out
lint: lint:
golangci-lint run --fix --timeout 10m golangci-lint run --fix --timeout 10m
@ -80,11 +74,4 @@ compress: build
generate: generate:
rm -rf gen rm -rf gen
go run github.com/bufbuild/buf/cmd/buf generate proto 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

72
acls.go
View file

@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/tailscale/hujson" "github.com/tailscale/hujson"
"go4.org/netipx" "go4.org/netipx"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -407,15 +408,40 @@ func generateACLPolicyDest(
needsWildcard bool, needsWildcard bool,
stripEmaildomain bool, stripEmaildomain bool,
) ([]tailcfg.NetPortRange, error) { ) ([]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 { 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 var alias string
// We can have here stuff like: // We can have here stuff like:
// git-server:* // git-server:*
// 192.168.1.0/24:22 // 192.168.1.0/24:22
// fd7a:115c:a1e0::2:22
// fd7a:115c:a1e0::2/128:22
// tag:montreal-webserver:80,443 // tag:montreal-webserver:80,443
// tag:api-server:443 // tag:api-server:443
// example-host-1:* // example-host-1:*
@ -508,9 +534,11 @@ func parseProtocol(protocol string) ([]int, bool, error) {
// - a group // - a group
// - a tag // - a tag
// - a host // - a host
// - an ip
// - a cidr
// and transform these in IPAddresses. // and transform these in IPAddresses.
func expandAlias( func expandAlias(
machines []Machine, machines Machines,
aclPolicy ACLPolicy, aclPolicy ACLPolicy,
alias string, alias string,
stripEmailDomain bool, stripEmailDomain bool,
@ -592,19 +620,40 @@ func expandAlias(
// if alias is an host // if alias is an host
if h, ok := aclPolicy.Hosts[alias]; ok { 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 // if alias is an IP
ip, err := netip.ParseAddr(alias) if ip, err := netip.ParseAddr(alias); err == nil {
if err == nil { log.Trace().Str("ip", ip.String()).Msg("expandAlias got ip")
return []string{ip.String()}, nil 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 if cidr, err := netip.ParsePrefix(alias); err == nil {
cidr, err := netip.ParsePrefix(alias) log.Trace().Str("cidr", cidr.String()).Msg("expandAlias got cidr")
if err == nil { val := []string{cidr.String()}
return []string{cidr.String()}, nil // 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) 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{} ports := []tailcfg.PortRange{}
for _, portStr := range strings.Split(portsStr, ",") { for _, portStr := range strings.Split(portsStr, ",") {
log.Trace().Msgf("parsing portstring: %s", portStr)
rang := strings.Split(portStr, "-") rang := strings.Split(portStr, "-")
switch len(rang) { switch len(rang) {
case 1: case 1:

View file

@ -1026,22 +1026,7 @@ func Test_expandAlias(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
name: "private network", name: "simple host by ip passed through",
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",
args: args{ args: args{
alias: "10.0.0.1", alias: "10.0.0.1",
machines: []Machine{}, machines: []Machine{},
@ -1051,6 +1036,62 @@ func Test_expandAlias(t *testing.T) {
want: []string{"10.0.0.1"}, want: []string{"10.0.0.1"},
wantErr: false, 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", name: "simple host by hostname alias",
args: args{ args: args{
@ -1066,6 +1107,21 @@ func Test_expandAlias(t *testing.T) {
want: []string{"10.0.0.132/32"}, want: []string{"10.0.0.132/32"},
wantErr: false, 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", name: "simple CIDR",
args: args{ args: args{

View file

@ -12,16 +12,14 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
const numberOfTestClients = 2 func aclScenario(t *testing.T, policy *headscale.ACLPolicy, clientsPerUser int) *Scenario {
func aclScenario(t *testing.T, policy headscale.ACLPolicy) *Scenario {
t.Helper() t.Helper()
scenario, err := NewScenario() scenario, err := NewScenario()
assert.NoError(t, err) assert.NoError(t, err)
spec := map[string]int{ spec := map[string]int{
"user1": numberOfTestClients, "user1": clientsPerUser,
"user2": numberOfTestClients, "user2": clientsPerUser,
} }
err = scenario.CreateHeadscaleEnv(spec, err = scenario.CreateHeadscaleEnv(spec,
@ -29,18 +27,15 @@ func aclScenario(t *testing.T, policy headscale.ACLPolicy) *Scenario {
tsic.WithDockerEntrypoint([]string{ tsic.WithDockerEntrypoint([]string{
"/bin/bash", "/bin/bash",
"-c", "-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("/"), tsic.WithDockerWorkdir("/"),
}, },
hsic.WithACLPolicy(&policy), hsic.WithACLPolicy(policy),
hsic.WithTestName("acl"), hsic.WithTestName("acl"),
) )
assert.NoError(t, err) assert.NoError(t, err)
// allClients, err := scenario.ListTailscaleClients()
// assert.NoError(t, err)
err = scenario.WaitForTailscaleSync() err = scenario.WaitForTailscaleSync()
assert.NoError(t, err) assert.NoError(t, err)
@ -230,7 +225,7 @@ func TestACLAllowUser80Dst(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
scenario := aclScenario(t, scenario := aclScenario(t,
headscale.ACLPolicy{ &headscale.ACLPolicy{
ACLs: []headscale.ACL{ ACLs: []headscale.ACL{
{ {
Action: "accept", Action: "accept",
@ -239,6 +234,7 @@ func TestACLAllowUser80Dst(t *testing.T) {
}, },
}, },
}, },
1,
) )
user1Clients, err := scenario.ListTailscaleClients("user1") user1Clients, err := scenario.ListTailscaleClients("user1")
@ -285,7 +281,7 @@ func TestACLDenyAllPort80(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
scenario := aclScenario(t, scenario := aclScenario(t,
headscale.ACLPolicy{ &headscale.ACLPolicy{
Groups: map[string][]string{ Groups: map[string][]string{
"group:integration-acl-test": {"user1", "user2"}, "group:integration-acl-test": {"user1", "user2"},
}, },
@ -297,6 +293,7 @@ func TestACLDenyAllPort80(t *testing.T) {
}, },
}, },
}, },
4,
) )
allClients, err := scenario.ListTailscaleClients() allClients, err := scenario.ListTailscaleClients()
@ -333,7 +330,7 @@ func TestACLAllowUserDst(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
scenario := aclScenario(t, scenario := aclScenario(t,
headscale.ACLPolicy{ &headscale.ACLPolicy{
ACLs: []headscale.ACL{ ACLs: []headscale.ACL{
{ {
Action: "accept", Action: "accept",
@ -342,6 +339,7 @@ func TestACLAllowUserDst(t *testing.T) {
}, },
}, },
}, },
2,
) )
user1Clients, err := scenario.ListTailscaleClients("user1") user1Clients, err := scenario.ListTailscaleClients("user1")
@ -390,7 +388,7 @@ func TestACLAllowStarDst(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
scenario := aclScenario(t, scenario := aclScenario(t,
headscale.ACLPolicy{ &headscale.ACLPolicy{
ACLs: []headscale.ACL{ ACLs: []headscale.ACL{
{ {
Action: "accept", Action: "accept",
@ -399,6 +397,7 @@ func TestACLAllowStarDst(t *testing.T) {
}, },
}, },
}, },
2,
) )
user1Clients, err := scenario.ListTailscaleClients("user1") user1Clients, err := scenario.ListTailscaleClients("user1")
@ -441,155 +440,6 @@ func TestACLAllowStarDst(t *testing.T) {
assert.NoError(t, err) 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 // TestACLNamedHostsCanReachBySubnet is the same as
// TestACLNamedHostsCanReach, but it tests if we expand a // TestACLNamedHostsCanReach, but it tests if we expand a
// full CIDR correctly. All routes should work. // full CIDR correctly. All routes should work.
@ -597,7 +447,7 @@ func TestACLNamedHostsCanReachBySubnet(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
scenario := aclScenario(t, scenario := aclScenario(t,
headscale.ACLPolicy{ &headscale.ACLPolicy{
Hosts: headscale.Hosts{ Hosts: headscale.Hosts{
"all": netip.MustParsePrefix("100.64.0.0/24"), "all": netip.MustParsePrefix("100.64.0.0/24"),
}, },
@ -610,6 +460,7 @@ func TestACLNamedHostsCanReachBySubnet(t *testing.T) {
}, },
}, },
}, },
3,
) )
user1Clients, err := scenario.ListTailscaleClients("user1") user1Clients, err := scenario.ListTailscaleClients("user1")
@ -651,3 +502,450 @@ func TestACLNamedHostsCanReachBySubnet(t *testing.T) {
err = scenario.Shutdown() err = scenario.Shutdown()
assert.NoError(t, err) 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)
})
}
}

View file

@ -46,3 +46,35 @@ func pingAllHelper(t *testing.T, clients []TailscaleClient, addrs []string) int
// //
// return failures // 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
// }

View file

@ -1267,3 +1267,17 @@ func (h *Headscale) GenerateGivenName(machineKey string, suppliedName string) (s
return givenName, nil 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
}