Compare commits

..

3 Commits

Author SHA1 Message Date
Kristoffer Dalby
b12ef62486 remove old release flow
Signed-off-by: Kristoffer Dalby <kristoffer@dalby.cc>
2023-04-28 10:08:45 +00:00
Kristoffer Dalby
df8a85f65a setup ko image builder for goreleaser
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-04-28 11:59:19 +02:00
Kristoffer Dalby
848a9c27ae make dockerfiles testing only note
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2023-04-28 11:32:28 +02:00
41 changed files with 793 additions and 1710 deletions

View File

@@ -6,24 +6,19 @@ labels: ["bug"]
assignees: "" assignees: ""
--- ---
<!-- <!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the bug report in this language. -->
Before posting a bug report, discuss the behaviour you are expecting with the Discord community
to make sure that it is truly a bug.
The issue tracker is not the place to ask for support or how to set up Headscale.
Bug reports without the sufficient information will be closed. **Bug description**
Headscale is a multinational community across the globe. Our language is English.
All bug reports needs to be in English.
-->
## Bug description
<!-- A clear and concise description of what the bug is. Describe the expected bahavior <!-- A clear and concise description of what the bug is. Describe the expected bahavior
and how it is currently different. If you are unsure if it is a bug, consider discussing and how it is currently different. If you are unsure if it is a bug, consider discussing
it on our Discord server first. --> it on our Discord server first. -->
## Environment **To Reproduce**
<!-- Steps to reproduce the behavior. -->
**Context info**
<!-- Please add relevant information about your system. For example: <!-- Please add relevant information about your system. For example:
- Version of headscale used - Version of headscale used
@@ -33,20 +28,3 @@ All bug reports needs to be in English.
- The relevant config parameters you used - The relevant config parameters you used
- Log output - Log output
--> -->
- OS:
- Headscale version:
- Tailscale version:
<!--
We do not support running Headscale in a container nor behind a (reverse) proxy.
If either of these are true for your environment, ask the community in Discord
instead of filing a bug report.
-->
- [ ] Headscale is behind a (reverse) proxy
- [ ] Headscale runs in a container
## To Reproduce
<!-- Steps to reproduce the behavior. -->

View File

@@ -6,21 +6,12 @@ labels: ["enhancement"]
assignees: "" assignees: ""
--- ---
<!-- <!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the feature request in this language. -->
We typically have a clear roadmap for what we want to improve and reserve the right
to close feature requests that does not fit in the roadmap, or fit with the scope
of the project, or we actually want to implement ourselves.
Headscale is a multinational community across the globe. Our language is English. **Feature request**
All bug reports needs to be in English.
-->
## Why
<!-- Include the reason, why you would need the feature. E.g. what problem
does it solve? Or which workflow is currently frustrating and will be improved by
this? -->
## Description
<!-- A clear and precise description of what new or changed feature you want. --> <!-- A clear and precise description of what new or changed feature you want. -->
<!-- Please include the reason, why you would need the feature. E.g. what problem
does it solve? Or which workflow is currently frustrating and will be improved by
this? -->

30
.github/ISSUE_TEMPLATE/other_issue.md vendored Normal file
View File

@@ -0,0 +1,30 @@
---
name: "Other issue"
about: "Report a different issue"
title: ""
labels: ["bug"]
assignees: ""
---
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the issue in this language. -->
<!-- If you have a question, please consider using our Discord for asking questions -->
**Issue description**
<!-- Please add your issue description. -->
**To Reproduce**
<!-- Steps to reproduce the behavior. -->
**Context info**
<!-- Please add relevant information about your system. For example:
- Version of headscale used
- Version of tailscale client
- OS (e.g. Linux, Mac, Cygwin, WSL, etc.) and version
- Kernel version
- The relevant config parameters you used
- Log output
-->

View File

@@ -1,15 +1,3 @@
<!--
Headscale is "Open Source, acknowledged contribution", this means that any
contribution will have to be discussed with the Maintainers before being submitted.
This model has been chosen to reduce the risk of burnout by limiting the
maintenance overhead of reviewing and validating third-party code.
Headscale is open to code contributions for bug fixes without discussion.
If you find mistakes in the documentation, please submit a fix to the documentation.
-->
<!-- Please tick if the following things apply. You… --> <!-- Please tick if the following things apply. You… -->
- [ ] read the [CONTRIBUTING guidelines](README.md#contributing) - [ ] read the [CONTRIBUTING guidelines](README.md#contributing)

26
.github/renovate.json vendored
View File

@@ -6,27 +6,31 @@
"onboarding": false, "onboarding": false,
"extends": ["config:base", ":rebaseStalePrs"], "extends": ["config:base", ":rebaseStalePrs"],
"ignorePresets": [":prHourlyLimit2"], "ignorePresets": [":prHourlyLimit2"],
"enabledManagers": ["dockerfile", "gomod", "github-actions", "regex"], "enabledManagers": ["dockerfile", "gomod", "github-actions","regex" ],
"includeForks": true, "includeForks": true,
"repositories": ["juanfont/headscale"], "repositories": ["juanfont/headscale"],
"platform": "github", "platform": "github",
"packageRules": [ "packageRules": [
{ {
"matchDatasources": ["go"], "matchDatasources": ["go"],
"groupName": "Go modules", "groupName": "Go modules",
"groupSlug": "gomod", "groupSlug": "gomod",
"separateMajorMinor": false "separateMajorMinor": false
}, },
{ {
"matchDatasources": ["docker"], "matchDatasources": ["docker"],
"groupName": "Dockerfiles", "groupName": "Dockerfiles",
"groupSlug": "dockerfiles" "groupSlug": "dockerfiles"
} }
], ],
"regexManagers": [ "regexManagers": [
{ {
"fileMatch": [".github/workflows/.*.yml$"], "fileMatch": [
"matchStrings": ["\\s*go-version:\\s*\"?(?<currentValue>.*?)\"?\\n"], ".github/workflows/.*.yml$"
],
"matchStrings": [
"\\s*go-version:\\s*\"?(?<currentValue>.*?)\"?\\n"
],
"datasourceTemplate": "golang-version", "datasourceTemplate": "golang-version",
"depNameTemplate": "actions/go-version" "depNameTemplate": "actions/go-version"
} }

View File

@@ -1,138 +0,0 @@
---
name: Release Docker
on:
push:
tags:
- "*" # triggers only if push new tag version
workflow_dispatch:
jobs:
docker-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Set up QEMU for multiple platforms
uses: docker/setup-qemu-action@master
with:
platforms: arm64,amd64
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
# list of Docker images to use as base name for tags
images: |
${{ secrets.DOCKERHUB_USERNAME }}/headscale
ghcr.io/${{ github.repository_owner }}/headscale
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=develop
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
push: true
context: .
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
build-args: |
VERSION=${{ steps.meta.outputs.version }}
- name: Prepare cache for next build
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
docker-debug-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Set up QEMU for multiple platforms
uses: docker/setup-qemu-action@master
with:
platforms: arm64,amd64
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache-debug
key: ${{ runner.os }}-buildx-debug-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-debug-
- name: Docker meta
id: meta-debug
uses: docker/metadata-action@v3
with:
# list of Docker images to use as base name for tags
images: |
${{ secrets.DOCKERHUB_USERNAME }}/headscale
ghcr.io/${{ github.repository_owner }}/headscale
flavor: |
suffix=-debug,onlatest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=develop
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
push: true
context: .
file: Dockerfile.debug
tags: ${{ steps.meta-debug.outputs.tags }}
labels: ${{ steps.meta-debug.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache-debug
cache-to: type=local,dest=/tmp/.buildx-cache-debug-new
build-args: |
VERSION=${{ steps.meta-debug.outputs.version }}
- name: Prepare cache for next build
run: |
rm -rf /tmp/.buildx-cache-debug
mv /tmp/.buildx-cache-debug-new /tmp/.buildx-cache-debug

View File

@@ -36,6 +36,7 @@ archives:
format: binary format: binary
source: source:
rlcp: true
enabled: true enabled: true
name_template: "{{ .ProjectName }}_{{ .Version }}" name_template: "{{ .ProjectName }}_{{ .Version }}"
format: tar.gz format: tar.gz
@@ -63,7 +64,6 @@ nfpms:
bindir: /usr/bin bindir: /usr/bin
formats: formats:
- deb - deb
# - rpm
contents: contents:
- src: ./config-example.yaml - src: ./config-example.yaml
dst: /etc/headscale/config.yaml dst: /etc/headscale/config.yaml
@@ -71,7 +71,7 @@ nfpms:
file_info: file_info:
mode: 0644 mode: 0644
- src: ./docs/packaging/headscale.systemd.service - src: ./docs/packaging/headscale.systemd.service
dst: /usr/lib/systemd/system/headscale.service dst: /etc/systemd/system/headscale.service
- dst: /var/lib/headscale - dst: /var/lib/headscale
type: dir type: dir
- dst: /var/run/headscale - dst: /var/run/headscale
@@ -80,6 +80,36 @@ nfpms:
postinstall: ./docs/packaging/postinstall.sh postinstall: ./docs/packaging/postinstall.sh
postremove: ./docs/packaging/postremove.sh postremove: ./docs/packaging/postremove.sh
kos:
- id: ghcr
build: headscale
base_image: gcr.io/distroless/base-debian11
repository: ghcr.io/juanfont/headscale
platforms:
- linux/amd64
- linux/386
- linux/arm64
- linux/arm/v7
- linux/arm/v6
- linux/arm/v5
tags:
- latest
- '{{.Tag}}'
- id: dockerhub
build: headscale
base_image: gcr.io/distroless/base-debian11
repository: headscale/headscale
platforms:
- linux/amd64
- linux/386
- linux/arm64
- linux/arm/v7
- linux/arm/v6
- linux/arm/v5
tags:
- latest
- '{{.Tag}}'
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
snapshot: snapshot:

View File

@@ -6,16 +6,12 @@
- Add environment flags to enable pprof (profiling) [#1382](https://github.com/juanfont/headscale/pull/1382) - Add environment flags to enable pprof (profiling) [#1382](https://github.com/juanfont/headscale/pull/1382)
- Profiles are continously generated in our integration tests. - Profiles are continously generated in our integration tests.
- Fix systemd service file location in `.deb` packages [#1391](https://github.com/juanfont/headscale/pull/1391)
- Improvements on Noise implementation [#1379](https://github.com/juanfont/headscale/pull/1379)
- Replace node filter logic, ensuring nodes with access can see eachother [#1381](https://github.com/juanfont/headscale/pull/1381)
## 0.22.1 (2023-04-20) ## 0.22.1 (2023-04-20)
### Changes ### Changes
- Fix issue where systemd could not bind to port 80 [#1365](https://github.com/juanfont/headscale/pull/1365) - Fix issue where SystemD could not bind to port 80 [#1365](https://github.com/juanfont/headscale/pull/1365)
- Disable (or delete) both exit routes at the same time [#1428](https://github.com/juanfont/headscale/pull/1428)
## 0.22.0 (2023-04-20) ## 0.22.0 (2023-04-20)

View File

@@ -1,4 +1,7 @@
# Builder image # This Dockerfile and the images produced are for testing headscale,
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM docker.io/golang:1.20-bullseye AS build FROM docker.io/golang:1.20-bullseye AS build
ARG VERSION=dev ARG VERSION=dev
ENV GOPATH /go ENV GOPATH /go

View File

@@ -1,4 +1,7 @@
# Builder image # This Dockerfile and the images produced are for testing headscale,
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM docker.io/golang:1.20-bullseye AS build FROM docker.io/golang:1.20-bullseye AS build
ARG VERSION=dev ARG VERSION=dev
ENV GOPATH /go ENV GOPATH /go

View File

@@ -1,16 +1,23 @@
FROM ubuntu:22.04 # This Dockerfile and the images produced are for testing headscale,
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM ubuntu:latest
ARG TAILSCALE_VERSION=* ARG TAILSCALE_VERSION=*
ARG TAILSCALE_CHANNEL=stable ARG TAILSCALE_CHANNEL=stable
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y gnupg curl ssh dnsutils ca-certificates \ && apt-get install -y gnupg curl ssh \
&& adduser --shell=/bin/bash ssh-it-user && curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.gpg | apt-key add - \
# Tailscale is deliberately split into a second stage so we can cash utils as a seperate layer.
RUN curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.gpg | apt-key add - \
&& curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \ && curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
&& apt-get update \ && apt-get update \
&& apt-get install -y tailscale=${TAILSCALE_VERSION} \ && apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN adduser --shell=/bin/bash ssh-it-user
ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/
RUN chmod 644 /usr/local/share/ca-certificates/server.crt
RUN update-ca-certificates

View File

@@ -1,7 +1,11 @@
# This Dockerfile and the images produced are for testing headscale,
# and are in no way endorsed by Headscale's maintainers as an
# official nor supported release or distribution.
FROM golang:latest FROM golang:latest
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y dnsutils git iptables ssh ca-certificates \ && apt-get install -y ca-certificates dnsutils git iptables ssh \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN useradd --shell=/bin/bash --create-home ssh-it-user RUN useradd --shell=/bin/bash --create-home ssh-it-user
@@ -10,8 +14,15 @@ RUN git clone https://github.com/tailscale/tailscale.git
WORKDIR /go/tailscale WORKDIR /go/tailscale
RUN git checkout main \ RUN git checkout main
&& sh build_dist.sh tailscale.com/cmd/tailscale \
&& sh build_dist.sh tailscale.com/cmd/tailscaled \ RUN sh build_dist.sh tailscale.com/cmd/tailscale
&& cp tailscale /usr/local/bin/ \ RUN sh build_dist.sh tailscale.com/cmd/tailscaled
&& cp tailscaled /usr/local/bin/
RUN cp tailscale /usr/local/bin/
RUN cp tailscaled /usr/local/bin/
ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/
RUN chmod 644 /usr/local/share/ca-certificates/server.crt
RUN update-ca-certificates

View File

@@ -32,18 +32,22 @@ organisation.
## Design goal ## Design goal
Headscale aims to implement a self-hosted, open source alternative to the Tailscale `headscale` aims to implement a self-hosted, open source alternative to the Tailscale
control server. control server. `headscale` has a narrower scope and an instance of `headscale`
Headscale's goal is to provide self-hosters and hobbyists with an open-source implements a _single_ Tailnet, which is typically what a single organisation, or
server they can use for their projects and labs. home/personal setup would use.
It implements a narrow scope, a single Tailnet, suitable for a personal use, or a small
open-source organisation.
## Supporting Headscale `headscale` uses terms that maps to Tailscale's control server, consult the
[glossary](./docs/glossary.md) for explainations.
## Support
If you like `headscale` and find it useful, there is a sponsorship and donation If you like `headscale` and find it useful, there is a sponsorship and donation
buttons available in the repo. buttons available in the repo.
If you would like to sponsor features, bugs or prioritisation, reach out to
one of the maintainers.
## Features ## Features
- Full "base" support of Tailscale's features - Full "base" support of Tailscale's features
@@ -75,10 +79,7 @@ buttons available in the repo.
## Running headscale ## Running headscale
**Please note that we do not support nor encourage the use of reverse proxies Please have a look at the documentation under [`docs/`](docs/).
and container to run Headscale.**
Please have a look at the [`documentation`](https://headscale.net/).
## Graphical Control Panels ## Graphical Control Panels
@@ -96,23 +97,11 @@ These are community projects not directly affiliated with the Headscale project.
## Disclaimer ## Disclaimer
1. This project is not associated with Tailscale Inc. 1. We have nothing to do with Tailscale, or Tailscale Inc.
2. The purpose of Headscale is maintaining a working, self-hosted Tailscale control panel. 2. The purpose of Headscale is maintaining a working, self-hosted Tailscale control panel.
## Contributing ## Contributing
Headscale is "Open Source, acknowledged contribution", this means that any
contribution will have to be discussed with the Maintainers before being submitted.
This model has been chosen to reduce the risk of burnout by limiting the
maintenance overhead of reviewing and validating third-party code.
Headscale is open to code contributions for bug fixes without discussion.
If you find mistakes in the documentation, please submit a fix to the documentation.
### Requirements
To contribute to headscale you would need the lastest version of [Go](https://golang.org) To contribute to headscale you would need the lastest version of [Go](https://golang.org)
and [Buf](https://buf.build)(Protobuf generator). and [Buf](https://buf.build)(Protobuf generator).
@@ -120,6 +109,8 @@ We recommend using [Nix](https://nixos.org/) to setup a development environment.
be done with `nix develop`, which will install the tools and give you a shell. be done with `nix develop`, which will install the tools and give you a shell.
This guarantees that you will have the same dev env as `headscale` maintainers. This guarantees that you will have the same dev env as `headscale` maintainers.
PRs and suggestions are welcome.
### Code style ### Code style
To ensure we have some consistency with a growing number of contributions, To ensure we have some consistency with a growing number of contributions,

492
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"
@@ -127,14 +128,21 @@ func (h *Headscale) UpdateACLRules() error {
return errEmptyPolicy return errEmptyPolicy
} }
rules, err := h.aclPolicy.generateFilterRules(machines, h.cfg.OIDC.StripEmaildomain) rules, err := generateACLRules(machines, *h.aclPolicy, h.cfg.OIDC.StripEmaildomain)
if err != nil { if err != nil {
return err return err
} }
log.Trace().Interface("ACL", rules).Msg("ACL rules generated") log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
h.aclRules = rules h.aclRules = rules
// Precompute a map of which sources can reach each destination, this is
// to provide quicker lookup when we calculate the peerlist for the map
// response to nodes.
aclPeerCacheMap := generateACLPeerCacheMap(rules)
h.aclPeerCacheMapRW.Lock()
h.aclPeerCacheMap = aclPeerCacheMap
h.aclPeerCacheMapRW.Unlock()
if featureEnableSSH() { if featureEnableSSH() {
sshRules, err := h.generateSSHRules() sshRules, err := h.generateSSHRules()
if err != nil { if err != nil {
@@ -152,28 +160,88 @@ func (h *Headscale) UpdateACLRules() error {
return nil return nil
} }
// generateFilterRules takes a set of machines and an ACLPolicy and generates a // generateACLPeerCacheMap takes a list of Tailscale filter rules and generates a map
// set of Tailscale compatible FilterRules used to allow traffic on clients. // of which Sources ("*" and IPs) can access destinations. This is to speed up the
func (pol *ACLPolicy) generateFilterRules( // process of generating MapResponses when deciding which Peers to inform nodes about.
func generateACLPeerCacheMap(rules []tailcfg.FilterRule) map[string][]string {
aclCachePeerMap := make(map[string][]string)
for _, rule := range rules {
for _, srcIP := range rule.SrcIPs {
for _, ip := range expandACLPeerAddr(srcIP) {
if data, ok := aclCachePeerMap[ip]; ok {
for _, dstPort := range rule.DstPorts {
data = append(data, dstPort.IP)
}
aclCachePeerMap[ip] = data
} else {
dstPortsMap := make([]string, 0)
for _, dstPort := range rule.DstPorts {
dstPortsMap = append(dstPortsMap, dstPort.IP)
}
aclCachePeerMap[ip] = dstPortsMap
}
}
}
}
log.Trace().Interface("ACL Cache Map", aclCachePeerMap).Msg("ACL Peer Cache Map generated")
return aclCachePeerMap
}
// expandACLPeerAddr takes a "tailcfg.FilterRule" "IP" and expands it into
// something our cache logic can look up, which is "*" or single IP addresses.
// This is probably quite inefficient, but it is a result of
// "make it work, then make it fast", and a lot of the ACL stuff does not
// work, but people have tried to make it fast.
func expandACLPeerAddr(srcIP string) []string {
if ip, err := netip.ParseAddr(srcIP); err == nil {
return []string{ip.String()}
}
if cidr, err := netip.ParsePrefix(srcIP); err == nil {
addrs := []string{}
ipRange := netipx.RangeOfPrefix(cidr)
from := ipRange.From()
too := ipRange.To()
if from == too {
return []string{from.String()}
}
for from != too && from.Less(too) {
addrs = append(addrs, from.String())
from = from.Next()
}
addrs = append(addrs, too.String()) // Add the last IP address in the range
return addrs
}
// probably "*" or other string based "IP"
return []string{srcIP}
}
func generateACLRules(
machines []Machine, machines []Machine,
stripEmailDomain bool, aclPolicy ACLPolicy,
stripEmaildomain bool,
) ([]tailcfg.FilterRule, error) { ) ([]tailcfg.FilterRule, error) {
rules := []tailcfg.FilterRule{} rules := []tailcfg.FilterRule{}
for index, acl := range pol.ACLs { for index, acl := range aclPolicy.ACLs {
if acl.Action != "accept" { if acl.Action != "accept" {
return nil, errInvalidAction return nil, errInvalidAction
} }
srcIPs := []string{} srcIPs := []string{}
for srcIndex, src := range acl.Sources { for innerIndex, src := range acl.Sources {
srcs, err := pol.getIPsFromSource(src, machines, stripEmailDomain) srcs, err := generateACLPolicySrc(machines, aclPolicy, src, stripEmaildomain)
if err != nil { if err != nil {
log.Error(). log.Error().
Interface("src", src). Msgf("Error parsing ACL %d, Source %d", index, innerIndex)
Int("ACL index", index).
Int("Src index", srcIndex).
Msgf("Error parsing ACL")
return nil, err return nil, err
} }
@@ -189,19 +257,17 @@ func (pol *ACLPolicy) generateFilterRules(
} }
destPorts := []tailcfg.NetPortRange{} destPorts := []tailcfg.NetPortRange{}
for destIndex, dest := range acl.Destinations { for innerIndex, dest := range acl.Destinations {
dests, err := pol.getNetPortRangeFromDestination( dests, err := generateACLPolicyDest(
dest,
machines, machines,
aclPolicy,
dest,
needsWildcard, needsWildcard,
stripEmailDomain, stripEmaildomain,
) )
if err != nil { if err != nil {
log.Error(). log.Error().
Interface("dest", dest). Msgf("Error parsing ACL %d, Destination %d", index, innerIndex)
Int("ACL index", index).
Int("dest index", destIndex).
Msgf("Error parsing ACL")
return nil, err return nil, err
} }
@@ -272,41 +338,22 @@ func (h *Headscale) generateSSHRules() ([]*tailcfg.SSHRule, error) {
principals := make([]*tailcfg.SSHPrincipal, 0, len(sshACL.Sources)) principals := make([]*tailcfg.SSHPrincipal, 0, len(sshACL.Sources))
for innerIndex, rawSrc := range sshACL.Sources { for innerIndex, rawSrc := range sshACL.Sources {
if isWildcard(rawSrc) { expandedSrcs, err := expandAlias(
machines,
*h.aclPolicy,
rawSrc,
h.cfg.OIDC.StripEmaildomain,
)
if err != nil {
log.Error().
Msgf("Error parsing SSH %d, Source %d", index, innerIndex)
return nil, err
}
for _, expandedSrc := range expandedSrcs {
principals = append(principals, &tailcfg.SSHPrincipal{ principals = append(principals, &tailcfg.SSHPrincipal{
Any: true, NodeIP: expandedSrc,
}) })
} else if isGroup(rawSrc) {
users, err := h.aclPolicy.getUsersInGroup(rawSrc, h.cfg.OIDC.StripEmaildomain)
if err != nil {
log.Error().
Msgf("Error parsing SSH %d, Source %d", index, innerIndex)
return nil, err
}
for _, user := range users {
principals = append(principals, &tailcfg.SSHPrincipal{
UserLogin: user,
})
}
} else {
expandedSrcs, err := h.aclPolicy.expandAlias(
machines,
rawSrc,
h.cfg.OIDC.StripEmaildomain,
)
if err != nil {
log.Error().
Msgf("Error parsing SSH %d, Source %d", index, innerIndex)
return nil, err
}
for _, expandedSrc := range expandedSrcs.Prefixes() {
principals = append(principals, &tailcfg.SSHPrincipal{
NodeIP: expandedSrc.Addr().String(),
})
}
} }
} }
@@ -315,9 +362,10 @@ func (h *Headscale) generateSSHRules() ([]*tailcfg.SSHRule, error) {
userMap[user] = "=" userMap[user] = "="
} }
rules = append(rules, &tailcfg.SSHRule{ rules = append(rules, &tailcfg.SSHRule{
Principals: principals, RuleExpires: nil,
SSHUsers: userMap, Principals: principals,
Action: &action, SSHUsers: userMap,
Action: &action,
}) })
} }
@@ -341,32 +389,19 @@ func sshCheckAction(duration string) (*tailcfg.SSHAction, error) {
}, nil }, nil
} }
// getIPsFromSource returns a set of Source IPs that would be associated func generateACLPolicySrc(
// with the given src alias.
func (pol *ACLPolicy) getIPsFromSource(
src string,
machines []Machine, machines []Machine,
aclPolicy ACLPolicy,
src string,
stripEmaildomain bool, stripEmaildomain bool,
) ([]string, error) { ) ([]string, error) {
ipSet, err := pol.expandAlias(machines, src, stripEmaildomain) return expandAlias(machines, aclPolicy, src, stripEmaildomain)
if err != nil {
return []string{}, err
}
prefixes := []string{}
for _, prefix := range ipSet.Prefixes() {
prefixes = append(prefixes, prefix.String())
}
return prefixes, nil
} }
// getNetPortRangeFromDestination returns a set of tailcfg.NetPortRange func generateACLPolicyDest(
// which are associated with the dest alias.
func (pol *ACLPolicy) getNetPortRangeFromDestination(
dest string,
machines []Machine, machines []Machine,
aclPolicy ACLPolicy,
dest string,
needsWildcard bool, needsWildcard bool,
stripEmaildomain bool, stripEmaildomain bool,
) ([]tailcfg.NetPortRange, error) { ) ([]tailcfg.NetPortRange, error) {
@@ -413,8 +448,9 @@ func (pol *ACLPolicy) getNetPortRangeFromDestination(
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1]) alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
} }
expanded, err := pol.expandAlias( expanded, err := expandAlias(
machines, machines,
aclPolicy,
alias, alias,
stripEmaildomain, stripEmaildomain,
) )
@@ -427,11 +463,11 @@ func (pol *ACLPolicy) getNetPortRangeFromDestination(
} }
dests := []tailcfg.NetPortRange{} dests := []tailcfg.NetPortRange{}
for _, dest := range expanded.Prefixes() { for _, d := range expanded {
for _, port := range *ports { for _, p := range *ports {
pr := tailcfg.NetPortRange{ pr := tailcfg.NetPortRange{
IP: dest.String(), IP: d,
Ports: port, Ports: p,
} }
dests = append(dests, pr) dests = append(dests, pr)
} }
@@ -498,64 +534,135 @@ func parseProtocol(protocol string) ([]int, bool, error) {
// - an ip // - an ip
// - a cidr // - a cidr
// and transform these in IPAddresses. // and transform these in IPAddresses.
func (pol *ACLPolicy) expandAlias( func expandAlias(
machines Machines, machines Machines,
aclPolicy ACLPolicy,
alias string, alias string,
stripEmailDomain bool, stripEmailDomain bool,
) (*netipx.IPSet, error) { ) ([]string, error) {
if isWildcard(alias) { ips := []string{}
return parseIPSet("*", nil) if alias == "*" {
return []string{"*"}, nil
} }
build := netipx.IPSetBuilder{}
log.Debug(). log.Debug().
Str("alias", alias). Str("alias", alias).
Msg("Expanding") Msg("Expanding")
// if alias is a group if strings.HasPrefix(alias, "group:") {
if isGroup(alias) { users, err := expandGroup(aclPolicy, alias, stripEmailDomain)
return pol.getIPsFromGroup(alias, machines, stripEmailDomain) if err != nil {
return ips, err
}
for _, n := range users {
nodes := filterMachinesByUser(machines, n)
for _, node := range nodes {
ips = append(ips, node.IPAddresses.ToStringSlice()...)
}
}
return ips, nil
} }
// if alias is a tag if strings.HasPrefix(alias, "tag:") {
if isTag(alias) { // check for forced tags
return pol.getIPsFromTag(alias, machines, stripEmailDomain) for _, machine := range machines {
if contains(machine.ForcedTags, alias) {
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
}
}
// find tag owners
owners, err := expandTagOwners(aclPolicy, alias, stripEmailDomain)
if err != nil {
if errors.Is(err, errInvalidTag) {
if len(ips) == 0 {
return ips, fmt.Errorf(
"%w. %v isn't owned by a TagOwner and no forced tags are defined",
errInvalidTag,
alias,
)
}
return ips, nil
} else {
return ips, err
}
}
// filter out machines per tag owner
for _, user := range owners {
machines := filterMachinesByUser(machines, user)
for _, machine := range machines {
hi := machine.GetHostInfo()
if contains(hi.RequestTags, alias) {
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
}
}
}
return ips, nil
} }
// if alias is a user // if alias is a user
if ips, err := pol.getIPsForUser(alias, machines, stripEmailDomain); ips != nil { nodes := filterMachinesByUser(machines, alias)
return ips, err nodes = excludeCorrectlyTaggedNodes(aclPolicy, nodes, alias, stripEmailDomain)
for _, n := range nodes {
ips = append(ips, n.IPAddresses.ToStringSlice()...)
}
if len(ips) > 0 {
return ips, nil
} }
// if alias is an host // if alias is an host
// Note, this is recursive. if h, ok := aclPolicy.Hosts[alias]; ok {
if h, ok := pol.Hosts[alias]; ok {
log.Trace().Str("host", h.String()).Msg("expandAlias got hosts entry") log.Trace().Str("host", h.String()).Msg("expandAlias got hosts entry")
return pol.expandAlias(machines, h.String(), stripEmailDomain) return expandAlias(machines, aclPolicy, h.String(), stripEmailDomain)
} }
// if alias is an IP // if alias is an IP
if ip, err := netip.ParseAddr(alias); err == nil { if ip, err := netip.ParseAddr(alias); err == nil {
return pol.getIPsFromSingleIP(ip, machines) 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 IP Prefix (CIDR) if cidr, err := netip.ParsePrefix(alias); err == nil {
if prefix, err := netip.ParsePrefix(alias); err == nil { log.Trace().Str("cidr", cidr.String()).Msg("expandAlias got cidr")
return pol.getIPsFromIPPrefix(prefix, machines) 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) log.Warn().Msgf("No IPs found with the alias %v", alias)
return build.IPSet() return ips, nil
} }
// excludeCorrectlyTaggedNodes will remove from the list of input nodes the ones // excludeCorrectlyTaggedNodes will remove from the list of input nodes the ones
// that are correctly tagged since they should not be listed as being in the user // that are correctly tagged since they should not be listed as being in the user
// we assume in this function that we only have nodes from 1 user. // we assume in this function that we only have nodes from 1 user.
func excludeCorrectlyTaggedNodes( func excludeCorrectlyTaggedNodes(
aclPolicy *ACLPolicy, aclPolicy ACLPolicy,
nodes []Machine, nodes []Machine,
user string, user string,
stripEmailDomain bool, stripEmailDomain bool,
@@ -563,7 +670,7 @@ func excludeCorrectlyTaggedNodes(
out := []Machine{} out := []Machine{}
tags := []string{} tags := []string{}
for tag := range aclPolicy.TagOwners { for tag := range aclPolicy.TagOwners {
owners, _ := getTagOwners(aclPolicy, user, stripEmailDomain) owners, _ := expandTagOwners(aclPolicy, user, stripEmailDomain)
ns := append(owners, user) ns := append(owners, user)
if contains(ns, user) { if contains(ns, user) {
tags = append(tags, tag) tags = append(tags, tag)
@@ -593,7 +700,7 @@ func excludeCorrectlyTaggedNodes(
} }
func expandPorts(portsStr string, needsWildcard bool) (*[]tailcfg.PortRange, error) { func expandPorts(portsStr string, needsWildcard bool) (*[]tailcfg.PortRange, error) {
if isWildcard(portsStr) { if portsStr == "*" {
return &[]tailcfg.PortRange{ return &[]tailcfg.PortRange{
{First: portRangeBegin, Last: portRangeEnd}, {First: portRangeBegin, Last: portRangeEnd},
}, nil }, nil
@@ -651,15 +758,15 @@ func filterMachinesByUser(machines []Machine, user string) []Machine {
return out return out
} }
// getTagOwners will return a list of user. An owner can be either a user or a group // expandTagOwners will return a list of user. An owner can be either a user or a group
// a group cannot be composed of groups. // a group cannot be composed of groups.
func getTagOwners( func expandTagOwners(
pol *ACLPolicy, aclPolicy ACLPolicy,
tag string, tag string,
stripEmailDomain bool, stripEmailDomain bool,
) ([]string, error) { ) ([]string, error) {
var owners []string var owners []string
ows, ok := pol.TagOwners[tag] ows, ok := aclPolicy.TagOwners[tag]
if !ok { if !ok {
return []string{}, fmt.Errorf( return []string{}, fmt.Errorf(
"%w. %v isn't owned by a TagOwner. Please add one first. https://tailscale.com/kb/1018/acls/#tag-owners", "%w. %v isn't owned by a TagOwner. Please add one first. https://tailscale.com/kb/1018/acls/#tag-owners",
@@ -668,8 +775,8 @@ func getTagOwners(
) )
} }
for _, owner := range ows { for _, owner := range ows {
if isGroup(owner) { if strings.HasPrefix(owner, "group:") {
gs, err := pol.getUsersInGroup(owner, stripEmailDomain) gs, err := expandGroup(aclPolicy, owner, stripEmailDomain)
if err != nil { if err != nil {
return []string{}, err return []string{}, err
} }
@@ -682,15 +789,15 @@ func getTagOwners(
return owners, nil return owners, nil
} }
// getUsersInGroup will return the list of user inside the group // expandGroup will return the list of user inside the group
// after some validation. // after some validation.
func (pol *ACLPolicy) getUsersInGroup( func expandGroup(
aclPolicy ACLPolicy,
group string, group string,
stripEmailDomain bool, stripEmailDomain bool,
) ([]string, error) { ) ([]string, error) {
users := []string{} outGroups := []string{}
log.Trace().Caller().Interface("pol", pol).Msg("test") aclGroups, ok := aclPolicy.Groups[group]
aclGroups, ok := pol.Groups[group]
if !ok { if !ok {
return []string{}, fmt.Errorf( return []string{}, fmt.Errorf(
"group %v isn't registered. %w", "group %v isn't registered. %w",
@@ -699,7 +806,7 @@ func (pol *ACLPolicy) getUsersInGroup(
) )
} }
for _, group := range aclGroups { for _, group := range aclGroups {
if isGroup(group) { if strings.HasPrefix(group, "group:") {
return []string{}, fmt.Errorf( return []string{}, fmt.Errorf(
"%w. A group cannot be composed of groups. https://tailscale.com/kb/1018/acls/#groups", "%w. A group cannot be composed of groups. https://tailscale.com/kb/1018/acls/#groups",
errInvalidGroup, errInvalidGroup,
@@ -713,151 +820,8 @@ func (pol *ACLPolicy) getUsersInGroup(
errInvalidGroup, errInvalidGroup,
) )
} }
users = append(users, grp) outGroups = append(outGroups, grp)
} }
return users, nil return outGroups, nil
}
func (pol *ACLPolicy) getIPsFromGroup(
group string,
machines Machines,
stripEmailDomain bool,
) (*netipx.IPSet, error) {
build := netipx.IPSetBuilder{}
users, err := pol.getUsersInGroup(group, stripEmailDomain)
if err != nil {
return &netipx.IPSet{}, err
}
for _, user := range users {
filteredMachines := filterMachinesByUser(machines, user)
for _, machine := range filteredMachines {
machine.IPAddresses.AppendToIPSet(&build)
}
}
return build.IPSet()
}
func (pol *ACLPolicy) getIPsFromTag(
alias string,
machines Machines,
stripEmailDomain bool,
) (*netipx.IPSet, error) {
build := netipx.IPSetBuilder{}
// check for forced tags
for _, machine := range machines {
if contains(machine.ForcedTags, alias) {
machine.IPAddresses.AppendToIPSet(&build)
}
}
// find tag owners
owners, err := getTagOwners(pol, alias, stripEmailDomain)
if err != nil {
if errors.Is(err, errInvalidTag) {
ipSet, _ := build.IPSet()
if len(ipSet.Prefixes()) == 0 {
return ipSet, fmt.Errorf(
"%w. %v isn't owned by a TagOwner and no forced tags are defined",
errInvalidTag,
alias,
)
}
return build.IPSet()
} else {
return nil, err
}
}
// filter out machines per tag owner
for _, user := range owners {
machines := filterMachinesByUser(machines, user)
for _, machine := range machines {
hi := machine.GetHostInfo()
if contains(hi.RequestTags, alias) {
machine.IPAddresses.AppendToIPSet(&build)
}
}
}
return build.IPSet()
}
func (pol *ACLPolicy) getIPsForUser(
user string,
machines Machines,
stripEmailDomain bool,
) (*netipx.IPSet, error) {
build := netipx.IPSetBuilder{}
filteredMachines := filterMachinesByUser(machines, user)
filteredMachines = excludeCorrectlyTaggedNodes(pol, filteredMachines, user, stripEmailDomain)
// shortcurcuit if we have no machines to get ips from.
if len(filteredMachines) == 0 {
return nil, nil //nolint
}
for _, machine := range filteredMachines {
machine.IPAddresses.AppendToIPSet(&build)
}
return build.IPSet()
}
func (pol *ACLPolicy) getIPsFromSingleIP(
ip netip.Addr,
machines Machines,
) (*netipx.IPSet, error) {
log.Trace().Str("ip", ip.String()).Msg("expandAlias got ip")
matches := machines.FilterByIP(ip)
build := netipx.IPSetBuilder{}
build.Add(ip)
for _, machine := range matches {
machine.IPAddresses.AppendToIPSet(&build)
}
return build.IPSet()
}
func (pol *ACLPolicy) getIPsFromIPPrefix(
prefix netip.Prefix,
machines Machines,
) (*netipx.IPSet, error) {
log.Trace().Str("prefix", prefix.String()).Msg("expandAlias got prefix")
build := netipx.IPSetBuilder{}
build.AddPrefix(prefix)
// This is suboptimal and quite expensive, but if we only add the prefix, 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 prefix (%s): %v, is single ip prefix (%v), addr: %s", ip.String(), prefix.String(), prefix.Contains(ip), prefix.IsSingleIP(), prefix.Addr().String())
if prefix.Contains(ip) {
machine.IPAddresses.AppendToIPSet(&build)
}
}
}
return build.IPSet()
}
func isWildcard(str string) bool {
return str == "*"
}
func isGroup(str string) bool {
return strings.HasPrefix(str, "group:")
}
func isTag(str string) bool {
return strings.HasPrefix(str, "tag:")
} }

File diff suppressed because it is too large Load Diff

View File

@@ -111,8 +111,8 @@ func (hosts *Hosts) UnmarshalYAML(data []byte) error {
} }
// IsZero is perhaps a bit naive here. // IsZero is perhaps a bit naive here.
func (pol ACLPolicy) IsZero() bool { func (policy ACLPolicy) IsZero() bool {
if len(pol.Groups) == 0 && len(pol.Hosts) == 0 && len(pol.ACLs) == 0 { if len(policy.Groups) == 0 && len(policy.Hosts) == 0 && len(policy.ACLs) == 0 {
return true return true
} }

8
app.go
View File

@@ -84,9 +84,11 @@ type Headscale struct {
DERPMap *tailcfg.DERPMap DERPMap *tailcfg.DERPMap
DERPServer *DERPServer DERPServer *DERPServer
aclPolicy *ACLPolicy aclPolicy *ACLPolicy
aclRules []tailcfg.FilterRule aclRules []tailcfg.FilterRule
sshPolicy *tailcfg.SSHPolicy aclPeerCacheMapRW sync.RWMutex
aclPeerCacheMap map[string][]string
sshPolicy *tailcfg.SSHPolicy
lastStateChange *xsync.MapOf[string, time.Time] lastStateChange *xsync.MapOf[string, time.Time]

View File

@@ -1,47 +0,0 @@
package main
import (
"log"
"github.com/juanfont/headscale/integration"
"github.com/juanfont/headscale/integration/tsic"
"github.com/ory/dockertest/v3"
)
func main() {
log.Printf("creating docker pool")
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("could not connect to docker: %s", err)
}
log.Printf("creating docker network")
network, err := pool.CreateNetwork("docker-integration-net")
if err != nil {
log.Fatalf("failed to create or get network: %s", err)
}
for _, version := range integration.TailscaleVersions {
log.Printf("creating container image for Tailscale (%s)", version)
tsClient, err := tsic.New(
pool,
version,
network,
)
if err != nil {
log.Fatalf("failed to create tailscale node: %s", err)
}
err = tsClient.Shutdown()
if err != nil {
log.Fatalf("failed to shut down container: %s", err)
}
}
network.Close()
err = pool.RemoveNetwork(network)
if err != nil {
log.Fatalf("failed to remove network: %s", err)
}
}

View File

@@ -58,12 +58,11 @@ noise:
# List of IP prefixes to allocate tailaddresses from. # List of IP prefixes to allocate tailaddresses from.
# Each prefix consists of either an IPv4 or IPv6 address, # Each prefix consists of either an IPv4 or IPv6 address,
# and the associated prefix length, delimited by a slash. # and the associated prefix length, delimited by a slash.
# It must be within IP ranges supported by the Tailscale # While this looks like it can take arbitrary values, it
# client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. # needs to be within IP ranges supported by the Tailscale
# See below: # client.
# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71 # IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71
# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33 # IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33
# Any other range is NOT supported, and it will cause unexpected issues.
ip_prefixes: ip_prefixes:
- fd7a:115c:a1e0::/48 - fd7a:115c:a1e0::/48
- 100.64.0.0/10 - 100.64.0.0/10

View File

@@ -16,7 +16,6 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"go4.org/netipx" "go4.org/netipx"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/dnstype" "tailscale.com/types/dnstype"
) )
@@ -516,29 +515,6 @@ func GetHeadscaleConfig() (*Config, error) {
if err != nil { if err != nil {
panic(fmt.Errorf("failed to parse ip_prefixes[%d]: %w", i, err)) panic(fmt.Errorf("failed to parse ip_prefixes[%d]: %w", i, err))
} }
if prefix.Addr().Is4() {
builder := netipx.IPSetBuilder{}
builder.AddPrefix(tsaddr.CGNATRange())
ipSet, _ := builder.IPSet()
if !ipSet.ContainsPrefix(prefix) {
log.Warn().
Msgf("Prefix %s is not in the %s range. This is an unsupported configuration.",
prefixInConfig, tsaddr.CGNATRange())
}
}
if prefix.Addr().Is6() {
builder := netipx.IPSetBuilder{}
builder.AddPrefix(tsaddr.TailscaleULARange())
ipSet, _ := builder.IPSet()
if !ipSet.ContainsPrefix(prefix) {
log.Warn().
Msgf("Prefix %s is not in the %s range. This is an unsupported configuration.",
prefixInConfig, tsaddr.TailscaleULARange())
}
}
parsedPrefixes = append(parsedPrefixes, prefix) parsedPrefixes = append(parsedPrefixes, prefix)
} }

View File

@@ -14,8 +14,6 @@ If the node is already registered, it can advertise exit capabilities like this:
$ sudo tailscale set --advertise-exit-node $ sudo tailscale set --advertise-exit-node
``` ```
To use a node as an exit node, IP forwarding must be enabled on the node. Check the official [Tailscale documentation](https://tailscale.com/kb/1019/subnets/?tab=linux#enable-ip-forwarding) for how to enable IP fowarding.
## On the control server ## On the control server
```console ```console

View File

@@ -1,53 +0,0 @@
---
hide:
- navigation
---
# Frequently Asked Questions
## What is the design goal of headscale?
`headscale` aims to implement a self-hosted, open source alternative to the [Tailscale](https://tailscale.com/)
control server.
`headscale`'s goal is to provide self-hosters and hobbyists with an open-source
server they can use for their projects and labs.
It implements a narrow scope, a _single_ Tailnet, suitable for a personal use, or a small
open-source organisation.
## How can I contribute?
Headscale is "Open Source, acknowledged contribution", this means that any
contribution will have to be discussed with the Maintainers before being submitted.
Headscale is open to code contributions for bug fixes without discussion.
If you find mistakes in the documentation, please also submit a fix to the documentation.
## Why is 'acknowledged contribution' the chosen model?
Both maintainers have full-time jobs and families, and we want to avoid burnout. We also want to avoid frustration from contributors when their PRs are not accepted.
We are more than happy to exchange emails, or to have dedicated calls before a PR is submitted.
## When/Why is Feature X going to be implemented?
We don't know. We might be working on it. If you want to help, please send us a PR.
Please be aware that there are a number of reasons why we might not accept specific contributions:
- It is not possible to implement the feature in a way that makes sense in a self-hosted environment.
- Given that we are reverse-engineering Tailscale to satify our own curiosity, we might be interested in implementing the feature ourselves.
- You are not sending unit and integration tests with it.
## Do you support Y method of deploying Headscale?
We currently support deploying `headscale` using our binaries and the DEB packages. Both can be found in the
[GitHub releases page](https://github.com/juanfont/headscale/releases).
In addition to that, there are semi-official RPM packages by the Fedora infra team https://copr.fedorainfracloud.org/coprs/jonathanspw/headscale/
For convenience, we also build Docker images with `headscale`. But **please be aware that we don't officially support deploying `headscale` using Docker**. We have a [Discord channel](https://discord.com/channels/896711691637780480/1070619770942148618) where you can ask for Docker-specific help to the community.
## Why is my reverse proxy not working with Headscale?
We don't know. We don't use reverse proxies with `headscale` ourselves, so we don't have any experience with them. We have [community documentation](https://headscale.net/reverse-proxy/) on how to configure various reverse proxies, and a dedicated [Discord channel](https://discord.com/channels/896711691637780480/1070619818346164324) where you can ask for help to the community.

View File

@@ -4,40 +4,9 @@ hide:
- toc - toc
--- ---
# headscale # headscale documentation
`headscale` is an open source, self-hosted implementation of the Tailscale control server. This site contains the official and community contributed documentation for `headscale`.
This page contains the documentation for the latest version of headscale. Please also check our [FAQ](/faq/). If you are having trouble with following the documentation or get unexpected results,
please ask on [Discord](https://discord.gg/c84AZQhmpx) instead of opening an Issue.
Join our [Discord](https://discord.gg/c84AZQhmpx) server for a chat and community support.
## Design goal
Headscale aims to implement a self-hosted, open source alternative to the Tailscale
control server.
Headscale's goal is to provide self-hosters and hobbyists with an open-source
server they can use for their projects and labs.
It implements a narrower scope, a single Tailnet, suitable for a personal use, or a small
open-source organisation.
## Supporting headscale
If you like `headscale` and find it useful, there is a sponsorship and donation
buttons available in the repo.
## Contributing
Headscale is "Open Source, acknowledged contribution", this means that any
contribution will have to be discussed with the Maintainers before being submitted.
This model has been chosen to reduce the risk of burnout by limiting the
maintenance overhead of reviewing and validating third-party code.
Headscale is open to code contributions for bug fixes without discussion.
If you find mistakes in the documentation, please submit a fix to the documentation.
## About
`headscale` is maintained by [Kristoffer Dalby](https://kradalby.no/) and [Juan Font](https://font.eu).

View File

@@ -20,7 +20,7 @@ configuration (`/etc/headscale/config.yaml`).
## Installation ## Installation
1. Download the lastest Headscale package for your platform (`.deb` for Ubuntu and Debian) from [Headscale's releases page](https://github.com/juanfont/headscale/releases): 1. Download the lastest Headscale package for your platform (`.deb` for Ubuntu and Debian) from [Headscale's releases page]():
```shell ```shell
wget --output-document=headscale.deb \ wget --output-document=headscale.deb \

View File

@@ -36,7 +36,7 @@
# When updating go.mod or go.sum, a new sha will need to be calculated, # When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to thos files. # update this if you have a mismatch after doing a change to thos files.
vendorSha256 = "sha256-cmDNYWYTgQp6CPgpL4d3TbkpAe7rhNAF+o8njJsgL7E="; vendorSha256 = "sha256-Gu0RhzXnrZ5705X/1CbTfmZlIG9PkxBZzs1bqWQPqWg=";
ldflags = [ "-s" "-w" "-X github.com/juanfont/headscale/cmd/headscale/cli.Version=v${version}" ]; ldflags = [ "-s" "-w" "-X github.com/juanfont/headscale/cmd/headscale/cli.Version=v${version}" ];
}; };
@@ -99,7 +99,6 @@
goreleaser goreleaser
nfpm nfpm
gotestsum gotestsum
gotests
# 'dot' is needed for pprof graphs # 'dot' is needed for pprof graphs
# go tool pprof -http=: <source> # go tool pprof -http=: <source>

3
go.mod
View File

@@ -11,7 +11,6 @@ require (
github.com/efekarakus/termcolor v1.0.1 github.com/efekarakus/termcolor v1.0.1
github.com/glebarez/sqlite v1.7.0 github.com/glebarez/sqlite v1.7.0
github.com/gofrs/uuid/v5 v5.0.0 github.com/gofrs/uuid/v5 v5.0.0
github.com/google/go-cmp v0.5.9
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.2 github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.2
@@ -74,6 +73,7 @@ require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/go-github v17.0.0+incompatible // indirect github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect
@@ -143,7 +143,6 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gotest.tools/v3 v3.4.0 // indirect
modernc.org/libc v1.22.2 // indirect modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect

3
go.sum
View File

@@ -900,8 +900,7 @@ gorm.io/driver/postgres v1.4.8/go.mod h1:O9MruWGNLUBUWVYfWuBClpf3HeGjOoybY0SNmCs
gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s= gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s=
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= gotest.tools/v3 v3.2.0 h1:I0DwBVMGAx26dttAj1BtJLAkVGncrkkUXfJLC4Flt/I=
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -12,39 +12,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
var veryLargeDestination = []string{
"0.0.0.0/5:*",
"8.0.0.0/7:*",
"11.0.0.0/8:*",
"12.0.0.0/6:*",
"16.0.0.0/4:*",
"32.0.0.0/3:*",
"64.0.0.0/2:*",
"128.0.0.0/3:*",
"160.0.0.0/5:*",
"168.0.0.0/6:*",
"172.0.0.0/12:*",
"172.32.0.0/11:*",
"172.64.0.0/10:*",
"172.128.0.0/9:*",
"173.0.0.0/8:*",
"174.0.0.0/7:*",
"176.0.0.0/4:*",
"192.0.0.0/9:*",
"192.128.0.0/11:*",
"192.160.0.0/13:*",
"192.169.0.0/16:*",
"192.170.0.0/15:*",
"192.172.0.0/14:*",
"192.176.0.0/12:*",
"192.192.0.0/10:*",
"193.0.0.0/8:*",
"194.0.0.0/7:*",
"196.0.0.0/6:*",
"200.0.0.0/5:*",
"208.0.0.0/4:*",
}
func aclScenario(t *testing.T, policy *headscale.ACLPolicy, clientsPerUser int) *Scenario { func aclScenario(t *testing.T, policy *headscale.ACLPolicy, clientsPerUser int) *Scenario {
t.Helper() t.Helper()
scenario, err := NewScenario() scenario, err := NewScenario()
@@ -209,34 +176,6 @@ func TestACLHostsInNetMapTable(t *testing.T) {
"user2": 3, // ns1 + ns2 (return path) "user2": 3, // ns1 + ns2 (return path)
}, },
}, },
"very-large-destination-prefix-1372": {
users: map[string]int{
"user1": 2,
"user2": 2,
},
policy: headscale.ACLPolicy{
ACLs: []headscale.ACL{
{
Action: "accept",
Sources: []string{"user1"},
Destinations: append([]string{"user1:*"}, veryLargeDestination...),
},
{
Action: "accept",
Sources: []string{"user2"},
Destinations: append([]string{"user2:*"}, veryLargeDestination...),
},
{
Action: "accept",
Sources: []string{"user1"},
Destinations: append([]string{"user2:*"}, veryLargeDestination...),
},
},
}, want: map[string]int{
"user1": 3, // ns1 + ns2
"user2": 3, // ns1 + ns2 (return path)
},
},
} }
for name, testCase := range tests { for name, testCase := range tests {
@@ -249,6 +188,7 @@ func TestACLHostsInNetMapTable(t *testing.T) {
err = scenario.CreateHeadscaleEnv(spec, err = scenario.CreateHeadscaleEnv(spec,
[]tsic.Option{}, []tsic.Option{},
hsic.WithACLPolicy(&testCase.policy), hsic.WithACLPolicy(&testCase.policy),
// hsic.WithTestName(fmt.Sprintf("aclinnetmap%s", name)),
) )
assert.NoError(t, err) assert.NoError(t, err)
@@ -258,6 +198,9 @@ func TestACLHostsInNetMapTable(t *testing.T) {
err = scenario.WaitForTailscaleSync() err = scenario.WaitForTailscaleSync()
assert.NoError(t, err) assert.NoError(t, err)
// allHostnames, err := scenario.ListTailscaleClientsFQDNs()
// assert.NoError(t, err)
for _, client := range allClients { for _, client := range allClients {
status, err := client.Status() status, err := client.Status()
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -33,7 +33,6 @@ var (
tailscaleVersions2021 = []string{ tailscaleVersions2021 = []string{
"head", "head",
"unstable", "unstable",
"1.40.0",
"1.38.4", "1.38.4",
"1.36.2", "1.36.2",
"1.34.2", "1.34.2",
@@ -280,7 +279,7 @@ func (s *Scenario) CreateTailscaleNodesInUser(
headscale, err := s.Headscale() headscale, err := s.Headscale()
if err != nil { if err != nil {
return fmt.Errorf("failed to create tailscale node (version: %s): %w", version, err) return fmt.Errorf("failed to create tailscale node: %w", err)
} }
cert := headscale.GetCert() cert := headscale.GetCert()

View File

@@ -424,7 +424,7 @@ func TestSSUserOnlyIsolation(t *testing.T) {
// TODO(kradalby,evenh): ACLs do currently not cover reject // TODO(kradalby,evenh): ACLs do currently not cover reject
// cases properly, and currently will accept all incomming connections // cases properly, and currently will accept all incomming connections
// as long as a rule is present. // as long as a rule is present.
//
// for _, client := range ssh1Clients { // for _, client := range ssh1Clients {
// for _, peer := range ssh2Clients { // for _, peer := range ssh2Clients {
// if client.Hostname() == peer.Hostname() { // if client.Hostname() == peer.Hostname() {

View File

@@ -212,11 +212,7 @@ func New(
dockertestutil.DockerAllowNetworkAdministration, dockertestutil.DockerAllowNetworkAdministration,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("could not start tailscale container: %w", err)
"could not start tailscale container (version: %s): %w",
version,
err,
)
} }
log.Printf("Created %s container\n", hostname) log.Printf("Created %s container\n", hostname)

View File

@@ -4,16 +4,17 @@ import (
"database/sql/driver" "database/sql/driver"
"errors" "errors"
"fmt" "fmt"
"net"
"net/netip" "net/netip"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1" v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/samber/lo" "github.com/samber/lo"
"go4.org/netipx"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm" "gorm.io/gorm"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@@ -98,14 +99,6 @@ func (ma MachineAddresses) ToStringSlice() []string {
return strSlice return strSlice
} }
// AppendToIPSet adds the individual ips in MachineAddresses to a
// given netipx.IPSetBuilder.
func (ma MachineAddresses) AppendToIPSet(build *netipx.IPSetBuilder) {
for _, ip := range ma {
build.Add(ip)
}
}
func (ma *MachineAddresses) Scan(destination interface{}) error { func (ma *MachineAddresses) Scan(destination interface{}) error {
switch value := destination.(type) { switch value := destination.(type) {
case string: case string:
@@ -169,48 +162,149 @@ func (machine *Machine) isEphemeral() bool {
return machine.AuthKey != nil && machine.AuthKey.Ephemeral return machine.AuthKey != nil && machine.AuthKey.Ephemeral
} }
func (machine *Machine) canAccess(filter []tailcfg.FilterRule, machine2 *Machine) bool {
for _, rule := range filter {
// TODO(kradalby): Cache or pregen this
matcher := MatchFromFilterRule(rule)
if !matcher.SrcsContainsIPs([]netip.Addr(machine.IPAddresses)) {
continue
}
if matcher.DestsContainsIP([]netip.Addr(machine2.IPAddresses)) {
return true
}
}
return false
}
// filterMachinesByACL wrapper function to not have devs pass around locks and maps // filterMachinesByACL wrapper function to not have devs pass around locks and maps
// related to the application outside of tests. // related to the application outside of tests.
func (h *Headscale) filterMachinesByACL(currentMachine *Machine, peers Machines) Machines { func (h *Headscale) filterMachinesByACL(currentMachine *Machine, peers Machines) Machines {
return filterMachinesByACL(currentMachine, peers, h.aclRules) return filterMachinesByACL(currentMachine, peers, &h.aclPeerCacheMapRW, h.aclPeerCacheMap)
} }
// filterMachinesByACL returns the list of peers authorized to be accessed from a given machine. // filterMachinesByACL returns the list of peers authorized to be accessed from a given machine.
func filterMachinesByACL( func filterMachinesByACL(
machine *Machine, machine *Machine,
machines Machines, machines Machines,
filter []tailcfg.FilterRule, lock *sync.RWMutex,
aclPeerCacheMap map[string][]string,
) Machines { ) Machines {
result := Machines{} log.Trace().
Caller().
Str("self", machine.Hostname).
Str("input", machines.String()).
Msg("Finding peers filtered by ACLs")
for index, peer := range machines { peers := make(map[uint64]Machine)
// Aclfilter peers here. We are itering through machines in all users and search through the computed aclRules
// for match between rule SrcIPs and DstPorts. If the rule is a match we allow the machine to be viewable.
machineIPs := machine.IPAddresses.ToStringSlice()
// TODO(kradalby): Remove this lock, I suspect its not a good idea, and might not be necessary,
// we only set this at startup atm (reading ACLs) and it might become a bottleneck.
lock.RLock()
for _, peer := range machines {
if peer.ID == machine.ID { if peer.ID == machine.ID {
continue continue
} }
peerIPs := peer.IPAddresses.ToStringSlice()
if machine.canAccess(filter, &machines[index]) || peer.canAccess(filter, machine) { if dstMap, ok := aclPeerCacheMap["*"]; ok {
result = append(result, peer) // match source and all destination
for _, dst := range dstMap {
if dst == "*" {
peers[peer.ID] = peer
continue
}
}
// match source and all destination
for _, peerIP := range peerIPs {
for _, dst := range dstMap {
_, cdr, _ := net.ParseCIDR(dst)
ip := net.ParseIP(peerIP)
if dst == peerIP || (cdr != nil && ip != nil && cdr.Contains(ip)) {
peers[peer.ID] = peer
continue
}
}
}
// match all sources and source
for _, machineIP := range machineIPs {
for _, dst := range dstMap {
_, cdr, _ := net.ParseCIDR(dst)
ip := net.ParseIP(machineIP)
if dst == machineIP || (cdr != nil && ip != nil && cdr.Contains(ip)) {
peers[peer.ID] = peer
continue
}
}
}
}
for _, machineIP := range machineIPs {
if dstMap, ok := aclPeerCacheMap[machineIP]; ok {
// match source and all destination
for _, dst := range dstMap {
if dst == "*" {
peers[peer.ID] = peer
continue
}
}
// match source and destination
for _, peerIP := range peerIPs {
for _, dst := range dstMap {
_, cdr, _ := net.ParseCIDR(dst)
ip := net.ParseIP(peerIP)
if dst == peerIP || (cdr != nil && ip != nil && cdr.Contains(ip)) {
peers[peer.ID] = peer
continue
}
}
}
}
}
for _, peerIP := range peerIPs {
if dstMap, ok := aclPeerCacheMap[peerIP]; ok {
// match source and all destination
for _, dst := range dstMap {
if dst == "*" {
peers[peer.ID] = peer
continue
}
}
// match return path
for _, machineIP := range machineIPs {
for _, dst := range dstMap {
_, cdr, _ := net.ParseCIDR(dst)
ip := net.ParseIP(machineIP)
if dst == machineIP || (cdr != nil && ip != nil && cdr.Contains(ip)) {
peers[peer.ID] = peer
continue
}
}
}
}
} }
} }
return result lock.RUnlock()
authorizedPeers := make(Machines, 0, len(peers))
for _, m := range peers {
authorizedPeers = append(authorizedPeers, m)
}
sort.Slice(
authorizedPeers,
func(i, j int) bool { return authorizedPeers[i].ID < authorizedPeers[j].ID },
)
log.Trace().
Caller().
Str("self", machine.Hostname).
Str("peers", authorizedPeers.String()).
Msg("Authorized peers")
return authorizedPeers
} }
func (h *Headscale) ListPeers(machine *Machine) (Machines, error) { func (h *Headscale) ListPeers(machine *Machine) (Machines, error) {
@@ -799,7 +893,7 @@ func getTags(
validTagMap := make(map[string]bool) validTagMap := make(map[string]bool)
invalidTagMap := make(map[string]bool) invalidTagMap := make(map[string]bool)
for _, tag := range machine.HostInfo.RequestTags { for _, tag := range machine.HostInfo.RequestTags {
owners, err := getTagOwners(aclPolicy, tag, stripEmailDomain) owners, err := expandTagOwners(*aclPolicy, tag, stripEmailDomain)
if errors.Is(err, errInvalidTag) { if errors.Is(err, errInvalidTag) {
invalidTagMap[tag] = true invalidTagMap[tag] = true
@@ -1113,7 +1207,7 @@ func (h *Headscale) EnableAutoApprovedRoutes(machine *Machine) error {
if approvedAlias == machine.User.Name { if approvedAlias == machine.User.Name {
approvedRoutes = append(approvedRoutes, advertisedRoute) approvedRoutes = append(approvedRoutes, advertisedRoute)
} else { } else {
approvedIps, err := h.aclPolicy.expandAlias([]Machine{*machine}, approvedAlias, h.cfg.OIDC.StripEmaildomain) approvedIps, err := expandAlias([]Machine{*machine}, *h.aclPolicy, approvedAlias, h.cfg.OIDC.StripEmaildomain)
if err != nil { if err != nil {
log.Err(err). log.Err(err).
Str("alias", approvedAlias). Str("alias", approvedAlias).
@@ -1123,7 +1217,7 @@ func (h *Headscale) EnableAutoApprovedRoutes(machine *Machine) error {
} }
// approvedIPs should contain all of machine's IPs if it matches the rule, so check for first // approvedIPs should contain all of machine's IPs if it matches the rule, so check for first
if approvedIps.Contains(machine.IPAddresses[0]) { if contains(approvedIps, machine.IPAddresses[0].String()) {
approvedRoutes = append(approvedRoutes, advertisedRoute) approvedRoutes = append(approvedRoutes, advertisedRoute)
} }
} }

View File

@@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strconv" "strconv"
"sync"
"testing" "testing"
"time" "time"
@@ -1040,12 +1041,16 @@ func Test_getFilteredByACLPeers(t *testing.T) {
}, },
}, },
} }
var lock sync.RWMutex
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
aclRulesMap := generateACLPeerCacheMap(tt.args.rules)
got := filterMachinesByACL( got := filterMachinesByACL(
tt.args.machine, tt.args.machine,
tt.args.machines, tt.args.machines,
tt.args.rules, &lock,
aclRulesMap,
) )
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
t.Errorf("filterMachinesByACL() = %v, want %v", got, tt.want) t.Errorf("filterMachinesByACL() = %v, want %v", got, tt.want)
@@ -1259,131 +1264,3 @@ func (s *Suite) TestAutoApproveRoutes(c *check.C) {
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
c.Assert(enabledRoutes, check.HasLen, 3) c.Assert(enabledRoutes, check.HasLen, 3)
} }
func TestMachine_canAccess(t *testing.T) {
type args struct {
filter []tailcfg.FilterRule
machine2 *Machine
}
tests := []struct {
name string
machine Machine
args args
want bool
}{
{
name: "no-rules",
machine: Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.1"),
},
},
args: args{
filter: []tailcfg.FilterRule{},
machine2: &Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.2"),
},
},
},
want: false,
},
{
name: "wildcard",
machine: Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.1"),
},
},
args: args{
filter: []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
},
machine2: &Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.2"),
},
},
},
want: true,
},
{
name: "explicit-m1-to-m2",
machine: Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.1"),
},
},
args: args{
filter: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.0.0.1"},
DstPorts: []tailcfg.NetPortRange{
{
IP: "10.0.0.2",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
},
machine2: &Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.2"),
},
},
},
want: true,
},
{
name: "explicit-m2-to-m1",
machine: Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.1"),
},
},
args: args{
filter: []tailcfg.FilterRule{
{
SrcIPs: []string{"10.0.0.2"},
DstPorts: []tailcfg.NetPortRange{
{
IP: "10.0.0.1",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
},
machine2: &Machine{
IPAddresses: MachineAddresses{
netip.MustParseAddr("10.0.0.2"),
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.machine.canAccess(tt.args.filter, tt.args.machine2); got != tt.want {
t.Errorf("Machine.canAccess() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -1,142 +0,0 @@
package headscale
import (
"fmt"
"net/netip"
"strings"
"go4.org/netipx"
"tailscale.com/tailcfg"
)
// This is borrowed from, and updated to use IPSet
// https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162
// TODO(kradalby): contribute upstream and make public.
var (
zeroIP4 = netip.AddrFrom4([4]byte{})
zeroIP6 = netip.AddrFrom16([16]byte{})
)
// parseIPSet parses arg as one:
//
// - an IP address (IPv4 or IPv6)
// - the string "*" to match everything (both IPv4 & IPv6)
// - a CIDR (e.g. "192.168.0.0/16")
// - a range of two IPs, inclusive, separated by hyphen ("2eff::1-2eff::0800")
//
// bits, if non-nil, is the legacy SrcBits CIDR length to make a IP
// address (without a slash) treated as a CIDR of *bits length.
// nolint
func parseIPSet(arg string, bits *int) (*netipx.IPSet, error) {
var ipSet netipx.IPSetBuilder
if arg == "*" {
ipSet.AddPrefix(netip.PrefixFrom(zeroIP4, 0))
ipSet.AddPrefix(netip.PrefixFrom(zeroIP6, 0))
return ipSet.IPSet()
}
if strings.Contains(arg, "/") {
pfx, err := netip.ParsePrefix(arg)
if err != nil {
return nil, err
}
if pfx != pfx.Masked() {
return nil, fmt.Errorf("%v contains non-network bits set", pfx)
}
ipSet.AddPrefix(pfx)
return ipSet.IPSet()
}
if strings.Count(arg, "-") == 1 {
ip1s, ip2s, _ := strings.Cut(arg, "-")
ip1, err := netip.ParseAddr(ip1s)
if err != nil {
return nil, err
}
ip2, err := netip.ParseAddr(ip2s)
if err != nil {
return nil, err
}
r := netipx.IPRangeFrom(ip1, ip2)
if !r.IsValid() {
return nil, fmt.Errorf("invalid IP range %q", arg)
}
for _, prefix := range r.Prefixes() {
ipSet.AddPrefix(prefix)
}
return ipSet.IPSet()
}
ip, err := netip.ParseAddr(arg)
if err != nil {
return nil, fmt.Errorf("invalid IP address %q", arg)
}
bits8 := uint8(ip.BitLen())
if bits != nil {
if *bits < 0 || *bits > int(bits8) {
return nil, fmt.Errorf("invalid CIDR size %d for IP %q", *bits, arg)
}
bits8 = uint8(*bits)
}
ipSet.AddPrefix(netip.PrefixFrom(ip, int(bits8)))
return ipSet.IPSet()
}
type Match struct {
Srcs *netipx.IPSet
Dests *netipx.IPSet
}
func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
srcs := new(netipx.IPSetBuilder)
dests := new(netipx.IPSetBuilder)
for _, srcIP := range rule.SrcIPs {
set, _ := parseIPSet(srcIP, nil)
srcs.AddSet(set)
}
for _, dest := range rule.DstPorts {
set, _ := parseIPSet(dest.IP, nil)
dests.AddSet(set)
}
srcsSet, _ := srcs.IPSet()
destsSet, _ := dests.IPSet()
match := Match{
Srcs: srcsSet,
Dests: destsSet,
}
return match
}
func (m *Match) SrcsContainsIPs(ips []netip.Addr) bool {
for _, ip := range ips {
if m.Srcs.Contains(ip) {
return true
}
}
return false
}
func (m *Match) DestsContainsIP(ips []netip.Addr) bool {
for _, ip := range ips {
if m.Dests.Contains(ip) {
return true
}
}
return false
}

View File

@@ -1,119 +0,0 @@
package headscale
import (
"net/netip"
"reflect"
"testing"
"go4.org/netipx"
)
func Test_parseIPSet(t *testing.T) {
set := func(ips []string, prefixes []string) *netipx.IPSet {
var builder netipx.IPSetBuilder
for _, ip := range ips {
builder.Add(netip.MustParseAddr(ip))
}
for _, pre := range prefixes {
builder.AddPrefix(netip.MustParsePrefix(pre))
}
s, _ := builder.IPSet()
return s
}
type args struct {
arg string
bits *int
}
tests := []struct {
name string
args args
want *netipx.IPSet
wantErr bool
}{
{
name: "simple ip4",
args: args{
arg: "10.0.0.1",
bits: nil,
},
want: set([]string{
"10.0.0.1",
}, []string{}),
wantErr: false,
},
{
name: "simple ip6",
args: args{
arg: "2001:db8:abcd:1234::2",
bits: nil,
},
want: set([]string{
"2001:db8:abcd:1234::2",
}, []string{}),
wantErr: false,
},
{
name: "wildcard",
args: args{
arg: "*",
bits: nil,
},
want: set([]string{}, []string{
"0.0.0.0/0",
"::/0",
}),
wantErr: false,
},
{
name: "prefix4",
args: args{
arg: "192.168.0.0/16",
bits: nil,
},
want: set([]string{}, []string{
"192.168.0.0/16",
}),
wantErr: false,
},
{
name: "prefix6",
args: args{
arg: "2001:db8:abcd:1234::/64",
bits: nil,
},
want: set([]string{}, []string{
"2001:db8:abcd:1234::/64",
}),
wantErr: false,
},
{
name: "range4",
args: args{
arg: "192.168.0.0-192.168.255.255",
bits: nil,
},
want: set([]string{}, []string{
"192.168.0.0/16",
}),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseIPSet(tt.args.arg, tt.args.bits)
if (err != nil) != tt.wantErr {
t.Errorf("parseIPSet() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseIPSet() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -1,6 +1,5 @@
site_name: Headscale site_name: Headscale
site_url: https://juanfont.github.io/headscale site_url: https://juanfont.github.io/headscale
edit_uri: blob/main/docs/ # Change the master branch to main as we are using main as a main branch
site_author: Headscale authors site_author: Headscale authors
site_description: >- site_description: >-
An open source, self-hosted implementation of the Tailscale control server. An open source, self-hosted implementation of the Tailscale control server.
@@ -122,7 +121,6 @@ markdown_extensions:
# Page tree # Page tree
nav: nav:
- Home: index.md - Home: index.md
- FAQ: faq.md
- Getting started: - Getting started:
- Installation: - Installation:
- Linux: running-headscale-linux.md - Linux: running-headscale-linux.md

112
noise.go
View File

@@ -1,9 +1,6 @@
package headscale package headscale
import ( import (
"encoding/binary"
"encoding/json"
"io"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@@ -12,37 +9,18 @@ import (
"golang.org/x/net/http2/h2c" "golang.org/x/net/http2/h2c"
"tailscale.com/control/controlbase" "tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp" "tailscale.com/control/controlhttp"
"tailscale.com/tailcfg" "tailscale.com/net/netutil"
"tailscale.com/types/key"
) )
const ( const (
// ts2021UpgradePath is the path that the server listens on for the WebSockets upgrade. // ts2021UpgradePath is the path that the server listens on for the WebSockets upgrade.
ts2021UpgradePath = "/ts2021" ts2021UpgradePath = "/ts2021"
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an "early payload"
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
// The early payload is optional. Some servers may not send it... But we do!
earlyPayloadMagic = "\xff\xff\xffTS"
// EarlyNoise was added in protocol version 49.
earlyNoiseCapabilityVersion = 49
) )
type noiseServer struct { type ts2021App struct {
headscale *Headscale headscale *Headscale
httpBaseConfig *http.Server conn *controlbase.Conn
http2Server *http2.Server
conn *controlbase.Conn
machineKey key.MachinePublic
nodeKey key.NodePublic
// EarlyNoise-related stuff
challenge key.ChallengePrivate
protocolVersion int
} }
// NoiseUpgradeHandler is to upgrade the connection and hijack the net.Conn // NoiseUpgradeHandler is to upgrade the connection and hijack the net.Conn
@@ -66,18 +44,7 @@ func (h *Headscale) NoiseUpgradeHandler(
return return
} }
noiseServer := noiseServer{ noiseConn, err := controlhttp.AcceptHTTP(req.Context(), writer, req, *h.noisePrivateKey, nil)
headscale: h,
challenge: key.NewChallenge(),
}
noiseConn, err := controlhttp.AcceptHTTP(
req.Context(),
writer,
req,
*h.noisePrivateKey,
noiseServer.earlyNoise,
)
if err != nil { if err != nil {
log.Error().Err(err).Msg("noise upgrade failed") log.Error().Err(err).Msg("noise upgrade failed")
http.Error(writer, err.Error(), http.StatusInternalServerError) http.Error(writer, err.Error(), http.StatusInternalServerError)
@@ -85,9 +52,10 @@ func (h *Headscale) NoiseUpgradeHandler(
return return
} }
noiseServer.conn = noiseConn ts2021App := ts2021App{
noiseServer.machineKey = noiseServer.conn.Peer() headscale: h,
noiseServer.protocolVersion = noiseServer.conn.ProtocolVersion() conn: noiseConn,
}
// This router is served only over the Noise connection, and exposes only the new API. // This router is served only over the Noise connection, and exposes only the new API.
// //
@@ -95,70 +63,16 @@ func (h *Headscale) NoiseUpgradeHandler(
// a single hijacked connection from /ts2021, using netutil.NewOneConnListener // a single hijacked connection from /ts2021, using netutil.NewOneConnListener
router := mux.NewRouter() router := mux.NewRouter()
router.HandleFunc("/machine/register", noiseServer.NoiseRegistrationHandler). router.HandleFunc("/machine/register", ts2021App.NoiseRegistrationHandler).
Methods(http.MethodPost) Methods(http.MethodPost)
router.HandleFunc("/machine/map", noiseServer.NoisePollNetMapHandler) router.HandleFunc("/machine/map", ts2021App.NoisePollNetMapHandler)
server := http.Server{ server := http.Server{
ReadTimeout: HTTPReadTimeout, ReadTimeout: HTTPReadTimeout,
} }
server.Handler = h2c.NewHandler(router, &http2.Server{})
noiseServer.httpBaseConfig = &http.Server{ err = server.Serve(netutil.NewOneConnListener(noiseConn, nil))
Handler: router,
ReadHeaderTimeout: HTTPReadTimeout,
}
noiseServer.http2Server = &http2.Server{}
server.Handler = h2c.NewHandler(router, noiseServer.http2Server)
noiseServer.http2Server.ServeConn(
noiseConn,
&http2.ServeConnOpts{
BaseConfig: noiseServer.httpBaseConfig,
},
)
}
func (ns *noiseServer) earlyNoise(protocolVersion int, writer io.Writer) error {
log.Trace().
Caller().
Int("protocol_version", protocolVersion).
Str("challenge", ns.challenge.Public().String()).
Msg("earlyNoise called")
if protocolVersion < earlyNoiseCapabilityVersion {
log.Trace().
Caller().
Msgf("protocol version %d does not support early noise", protocolVersion)
return nil
}
earlyJSON, err := json.Marshal(&tailcfg.EarlyNoise{
NodeKeyChallenge: ns.challenge.Public(),
})
if err != nil { if err != nil {
return err log.Info().Err(err).Msg("The HTTP2 server was closed")
} }
// 5 bytes that won't be mistaken for an HTTP/2 frame:
// https://httpwg.org/specs/rfc7540.html#rfc.section.4.1 (Especially not
// an HTTP/2 settings frame, which isn't of type 'T')
var notH2Frame [5]byte
copy(notH2Frame[:], earlyPayloadMagic)
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(earlyJSON)))
// These writes are all buffered by caller, so fine to do them
// separately:
if _, err := writer.Write(notH2Frame[:]); err != nil {
return err
}
if _, err := writer.Write(lenBuf[:]); err != nil {
return err
}
if _, err := writer.Write(earlyJSON); err != nil {
return err
}
return nil
} }

View File

@@ -10,7 +10,7 @@ import (
) )
// // NoiseRegistrationHandler handles the actual registration process of a machine. // // NoiseRegistrationHandler handles the actual registration process of a machine.
func (ns *noiseServer) NoiseRegistrationHandler( func (t *ts2021App) NoiseRegistrationHandler(
writer http.ResponseWriter, writer http.ResponseWriter,
req *http.Request, req *http.Request,
) { ) {
@@ -20,11 +20,6 @@ func (ns *noiseServer) NoiseRegistrationHandler(
return return
} }
log.Trace().
Any("headers", req.Header).
Msg("Headers")
body, _ := io.ReadAll(req.Body) body, _ := io.ReadAll(req.Body)
registerRequest := tailcfg.RegisterRequest{} registerRequest := tailcfg.RegisterRequest{}
if err := json.Unmarshal(body, &registerRequest); err != nil { if err := json.Unmarshal(body, &registerRequest); err != nil {
@@ -38,7 +33,5 @@ func (ns *noiseServer) NoiseRegistrationHandler(
return return
} }
ns.nodeKey = registerRequest.NodeKey t.headscale.handleRegisterCommon(writer, req, registerRequest, t.conn.Peer(), true)
ns.headscale.handleRegisterCommon(writer, req, registerRequest, ns.conn.Peer(), true)
} }

View File

@@ -21,18 +21,13 @@ import (
// only after their first request (marked with the ReadOnly field). // only after their first request (marked with the ReadOnly field).
// //
// At this moment the updates are sent in a quite horrendous way, but they kinda work. // At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (ns *noiseServer) NoisePollNetMapHandler( func (t *ts2021App) NoisePollNetMapHandler(
writer http.ResponseWriter, writer http.ResponseWriter,
req *http.Request, req *http.Request,
) { ) {
log.Trace(). log.Trace().
Str("handler", "NoisePollNetMap"). Str("handler", "NoisePollNetMap").
Msg("PollNetMapHandler called") Msg("PollNetMapHandler called")
log.Trace().
Any("headers", req.Header).
Msg("Headers")
body, _ := io.ReadAll(req.Body) body, _ := io.ReadAll(req.Body)
mapRequest := tailcfg.MapRequest{} mapRequest := tailcfg.MapRequest{}
@@ -46,9 +41,7 @@ func (ns *noiseServer) NoisePollNetMapHandler(
return return
} }
ns.nodeKey = mapRequest.NodeKey machine, err := t.headscale.GetMachineByAnyKey(t.conn.Peer(), mapRequest.NodeKey, key.NodePublic{})
machine, err := ns.headscale.GetMachineByAnyKey(ns.conn.Peer(), mapRequest.NodeKey, key.NodePublic{})
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn(). log.Warn().
@@ -70,5 +63,5 @@ func (ns *noiseServer) NoisePollNetMapHandler(
Str("machine", machine.Hostname). Str("machine", machine.Hostname).
Msg("A machine is entering polling via the Noise protocol") Msg("A machine is entering polling via the Noise protocol")
ns.headscale.handlePollCommon(writer, req.Context(), machine, mapRequest, true) t.headscale.handlePollCommon(writer, req.Context(), machine, mapRequest, true)
} }

View File

@@ -106,36 +106,13 @@ func (h *Headscale) DisableRoute(id uint64) error {
return err return err
} }
// Tailscale requires both IPv4 and IPv6 exit routes to route.Enabled = false
// be enabled at the same time, as per route.IsPrimary = false
// https://github.com/juanfont/headscale/issues/804#issuecomment-1399314002 err = h.db.Save(route).Error
if !route.isExitRoute() {
route.Enabled = false
route.IsPrimary = false
err = h.db.Save(route).Error
if err != nil {
return err
}
return h.handlePrimarySubnetFailover()
}
routes, err := h.GetMachineRoutes(&route.Machine)
if err != nil { if err != nil {
return err return err
} }
for i := range routes {
if routes[i].isExitRoute() {
routes[i].Enabled = false
routes[i].IsPrimary = false
err = h.db.Save(&routes[i]).Error
if err != nil {
return err
}
}
}
return h.handlePrimarySubnetFailover() return h.handlePrimarySubnetFailover()
} }
@@ -145,30 +122,7 @@ func (h *Headscale) DeleteRoute(id uint64) error {
return err return err
} }
// Tailscale requires both IPv4 and IPv6 exit routes to if err := h.db.Unscoped().Delete(&route).Error; err != nil {
// be enabled at the same time, as per
// https://github.com/juanfont/headscale/issues/804#issuecomment-1399314002
if !route.isExitRoute() {
if err := h.db.Unscoped().Delete(&route).Error; err != nil {
return err
}
return h.handlePrimarySubnetFailover()
}
routes, err := h.GetMachineRoutes(&route.Machine)
if err != nil {
return err
}
routesToDelete := []Route{}
for _, r := range routes {
if r.isExitRoute() {
routesToDelete = append(routesToDelete, r)
}
}
if err := h.db.Unscoped().Delete(&routesToDelete).Error; err != nil {
return err return err
} }

View File

@@ -457,37 +457,6 @@ func (s *Suite) TestAllowedIPRoutes(c *check.C) {
c.Assert(foundExitNodeV4, check.Equals, true) c.Assert(foundExitNodeV4, check.Equals, true)
c.Assert(foundExitNodeV6, check.Equals, true) c.Assert(foundExitNodeV6, check.Equals, true)
// Now we disable only one of the exit routes
// and we see if both are disabled
var exitRouteV4 Route
for _, route := range routes {
if route.isExitRoute() && netip.Prefix(route.Prefix) == prefixExitNodeV4 {
exitRouteV4 = route
break
}
}
err = app.DisableRoute(uint64(exitRouteV4.ID))
c.Assert(err, check.IsNil)
enabledRoutes1, err = app.GetEnabledRoutes(&machine1)
c.Assert(err, check.IsNil)
c.Assert(len(enabledRoutes1), check.Equals, 1)
// and now we delete only one of the exit routes
// and we check if both are deleted
routes, err = app.GetMachineRoutes(&machine1)
c.Assert(err, check.IsNil)
c.Assert(len(routes), check.Equals, 4)
err = app.DeleteRoute(uint64(exitRouteV4.ID))
c.Assert(err, check.IsNil)
routes, err = app.GetMachineRoutes(&machine1)
c.Assert(err, check.IsNil)
c.Assert(len(routes), check.Equals, 2)
} }
func (s *Suite) TestDeleteRoutes(c *check.C) { func (s *Suite) TestDeleteRoutes(c *check.C) {