├── .envrc ├── hscontrol ├── policy │ ├── matcher │ │ ├── matcher_test.go │ │ └── matcher.go │ ├── pm_test.go │ └── acls_types.go ├── types │ ├── testdata │ │ ├── minimal.yaml │ │ ├── base-domain-in-server-url.yaml │ │ ├── base-domain-not-in-server-url.yaml │ │ ├── policy-path-is-loaded.yaml │ │ ├── dns_full.yaml │ │ └── dns_full_no_magic.yaml │ ├── const.go │ ├── policy.go │ ├── api_key.go │ ├── preauth_key.go │ ├── routes_test.go │ └── routes.go ├── db │ ├── testdata │ │ ├── pre-24-postgresdb.pssql.dump │ │ ├── failing-node-preauth-constraint.sqlite │ │ ├── 0-23-0-to-0-24-0-no-more-special-types.sqlite │ │ ├── 0-23-0-to-0-24-0-preauthkey-tags-table.sqlite │ │ ├── 0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite │ │ └── 0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite │ ├── policy.go │ ├── api_key_test.go │ ├── preauth_keys_test.go │ ├── suite_test.go │ ├── text_serialiser.go │ └── api_key.go ├── util │ ├── const.go │ ├── key.go │ ├── net.go │ ├── string_test.go │ ├── test.go │ ├── util.go │ ├── file.go │ ├── string.go │ ├── addr_test.go │ ├── log.go │ └── addr.go ├── mapper │ ├── suite_test.go │ └── tail.go ├── grpcv1_test.go ├── templates │ ├── windows.go │ ├── register_web.go │ └── general.go ├── suite_test.go ├── capver │ ├── capver_generated.go │ ├── capver_test.go │ └── capver.go ├── notifier │ └── metrics.go ├── tailsql.go ├── auth_test.go └── derp │ └── derp.go ├── docs ├── about │ ├── contributing.md │ ├── sponsor.md │ ├── help.md │ ├── releases.md │ ├── clients.md │ ├── features.md │ └── faq.md ├── logo │ ├── headscale3-dots.pdf │ ├── headscale3-dots.png │ ├── headscale3_header_stacked_left.pdf │ ├── headscale3_header_stacked_left.png │ └── headscale3-dots.svg ├── images │ └── headscale-acl-network.png ├── requirements.txt ├── packaging │ ├── README.md │ ├── postremove.sh │ ├── headscale.systemd.service │ └── postinstall.sh ├── setup │ ├── upgrade.md │ ├── requirements.md │ └── install │ │ ├── community.md │ │ └── source.md ├── usage │ └── connect │ │ ├── android.md │ │ ├── windows.md │ │ └── apple.md ├── ref │ ├── integration │ │ ├── tools.md │ │ └── web-ui.md │ ├── exit-node.md │ └── configuration.md └── index.md ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ └── bug_report.yaml ├── workflows │ ├── update-flake.yml │ ├── gh-actions-updater.yaml │ ├── docs-test.yml │ ├── stale.yml │ ├── release.yml │ ├── test.yml │ ├── check-tests.yaml │ ├── docs-deploy.yml │ ├── gh-action-integration-generator.go │ ├── lint.yml │ └── build.yml ├── pull_request_template.md └── renovate.json ├── .prettierignore ├── proto ├── buf.yaml ├── buf.lock └── headscale │ └── v1 │ ├── policy.proto │ ├── apikey.proto │ ├── preauthkey.proto │ ├── user.proto │ ├── routes.proto │ ├── device.proto │ └── node.proto ├── cmd └── headscale │ ├── cli │ ├── pterm_style.go │ ├── version.go │ ├── configtest.go │ ├── dump_config.go │ ├── serve.go │ ├── generate.go │ ├── policy.go │ ├── root.go │ └── debug.go │ ├── headscale.go │ └── headscale_test.go ├── .dockerignore ├── .coderabbit.yaml ├── derp-example.yaml ├── buf.gen.yaml ├── Dockerfile.derper ├── .gitignore ├── integration ├── control.go ├── README.md ├── dockertestutil │ ├── config.go │ ├── logs.go │ ├── network.go │ └── execute.go ├── tailscale.go ├── hsic │ └── config.go ├── run.sh └── derp_verify_endpoint_test.go ├── Dockerfile.integration ├── gen └── openapiv2 │ └── headscale │ └── v1 │ ├── node.swagger.json │ ├── user.swagger.json │ ├── apikey.swagger.json │ ├── device.swagger.json │ ├── policy.swagger.json │ ├── routes.swagger.json │ └── preauthkey.swagger.json ├── .golangci.yaml ├── LICENSE ├── flake.lock ├── Dockerfile.tailscale-HEAD ├── Makefile ├── swagger.go └── CONTRIBUTING.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /hscontrol/policy/matcher/matcher_test.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | -------------------------------------------------------------------------------- /docs/about/contributing.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../../CONTRIBUTING.md" 3 | %} 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: headscale 4 | -------------------------------------------------------------------------------- /docs/logo/headscale3-dots.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/docs/logo/headscale3-dots.pdf -------------------------------------------------------------------------------- /docs/logo/headscale3-dots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/docs/logo/headscale3-dots.png -------------------------------------------------------------------------------- /hscontrol/types/testdata/minimal.yaml: -------------------------------------------------------------------------------- 1 | noise: 2 | private_key_path: "private_key.pem" 3 | server_url: "https://derp.no" 4 | -------------------------------------------------------------------------------- /docs/images/headscale-acl-network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/docs/images/headscale-acl-network.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/workflows/test-integration-v2* 2 | docs/about/features.md 3 | docs/ref/configuration.md 4 | docs/ref/remote-cli.md 5 | -------------------------------------------------------------------------------- /docs/logo/headscale3_header_stacked_left.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/docs/logo/headscale3_header_stacked_left.pdf -------------------------------------------------------------------------------- /docs/logo/headscale3_header_stacked_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/docs/logo/headscale3_header_stacked_left.png -------------------------------------------------------------------------------- /hscontrol/db/testdata/pre-24-postgresdb.pssql.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/hscontrol/db/testdata/pre-24-postgresdb.pssql.dump -------------------------------------------------------------------------------- /hscontrol/util/const.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | const ( 4 | RegisterMethodAuthKey = "authkey" 5 | RegisterMethodOIDC = "oidc" 6 | RegisterMethodCLI = "cli" 7 | ) 8 | -------------------------------------------------------------------------------- /hscontrol/db/testdata/failing-node-preauth-constraint.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/hscontrol/db/testdata/failing-node-preauth-constraint.sqlite -------------------------------------------------------------------------------- /hscontrol/db/testdata/0-23-0-to-0-24-0-no-more-special-types.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/hscontrol/db/testdata/0-23-0-to-0-24-0-no-more-special-types.sqlite -------------------------------------------------------------------------------- /hscontrol/db/testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/hscontrol/db/testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite -------------------------------------------------------------------------------- /docs/about/sponsor.md: -------------------------------------------------------------------------------- 1 | # Sponsor 2 | 3 | If you like to support the development of headscale, please consider a donation via 4 | [ko-fi.com/headscale](https://ko-fi.com/headscale). Thank you! 5 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mike~=2.1 2 | mkdocs-include-markdown-plugin~=7.1 3 | mkdocs-macros-plugin~=1.3 4 | mkdocs-material[imaging]~=9.5 5 | mkdocs-minify-plugin~=0.7 6 | mkdocs-redirects~=1.2 7 | -------------------------------------------------------------------------------- /hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite -------------------------------------------------------------------------------- /docs/packaging/README.md: -------------------------------------------------------------------------------- 1 | # Packaging 2 | 3 | We use [nFPM](https://nfpm.goreleaser.com/) for making `.deb`, `.rpm` and `.apk`. 4 | 5 | This folder contains files we need to package with these releases. 6 | -------------------------------------------------------------------------------- /hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leaningtech/headscale/HEAD/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite -------------------------------------------------------------------------------- /hscontrol/util/key.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | ErrCannotDecryptResponse = errors.New("cannot decrypt response") 9 | ZstdCompression = "zstd" 10 | ) 11 | -------------------------------------------------------------------------------- /docs/about/help.md: -------------------------------------------------------------------------------- 1 | # Getting help 2 | 3 | Join our [Discord server](https://discord.gg/c84AZQhmpx) for announcements and community support. 4 | 5 | Please report bugs via [GitHub issues](https://github.com/juanfont/headscale/issues) 6 | -------------------------------------------------------------------------------- /hscontrol/mapper/suite_test.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/check.v1" 7 | ) 8 | 9 | func Test(t *testing.T) { 10 | check.TestingT(t) 11 | } 12 | 13 | var _ = check.Suite(&Suite{}) 14 | 15 | type Suite struct{} 16 | -------------------------------------------------------------------------------- /hscontrol/util/net.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | func GrpcSocketDialer(ctx context.Context, addr string) (net.Conn, error) { 9 | var d net.Dialer 10 | 11 | return d.DialContext(ctx, "unix", addr) 12 | } 13 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | lint: 3 | use: 4 | - DEFAULT 5 | breaking: 6 | use: 7 | - FILE 8 | 9 | deps: 10 | - buf.build/googleapis/googleapis 11 | - buf.build/grpc-ecosystem/grpc-gateway 12 | - buf.build/ufoundit-dev/protoc-gen-gorm 13 | -------------------------------------------------------------------------------- /hscontrol/types/testdata/base-domain-in-server-url.yaml: -------------------------------------------------------------------------------- 1 | noise: 2 | private_key_path: "private_key.pem" 3 | 4 | prefixes: 5 | v6: fd7a:115c:a1e0::/48 6 | v4: 100.64.0.0/10 7 | 8 | database: 9 | type: sqlite3 10 | 11 | server_url: "https://server.derp.no" 12 | 13 | dns: 14 | magic_dns: true 15 | base_domain: derp.no 16 | -------------------------------------------------------------------------------- /hscontrol/types/testdata/base-domain-not-in-server-url.yaml: -------------------------------------------------------------------------------- 1 | noise: 2 | private_key_path: "private_key.pem" 3 | 4 | prefixes: 5 | v6: fd7a:115c:a1e0::/48 6 | v4: 100.64.0.0/10 7 | 8 | database: 9 | type: sqlite3 10 | 11 | server_url: "https://derp.no" 12 | 13 | dns: 14 | magic_dns: true 15 | base_domain: clients.derp.no 16 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @juanfont @kradalby 2 | 3 | *.md @ohdearaugustin @nblock 4 | *.yml @ohdearaugustin @nblock 5 | *.yaml @ohdearaugustin @nblock 6 | Dockerfile* @ohdearaugustin @nblock 7 | .goreleaser.yaml @ohdearaugustin @nblock 8 | /docs/ @ohdearaugustin @nblock 9 | /.github/workflows/ @ohdearaugustin @nblock 10 | /.github/renovate.json @ohdearaugustin @nblock 11 | -------------------------------------------------------------------------------- /cmd/headscale/cli/pterm_style.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pterm/pterm" 7 | ) 8 | 9 | func ColourTime(date time.Time) string { 10 | dateStr := date.Format("2006-01-02 15:04:05") 11 | 12 | if date.After(time.Now()) { 13 | dateStr = pterm.LightGreen(dateStr) 14 | } else { 15 | dateStr = pterm.LightRed(dateStr) 16 | } 17 | 18 | return dateStr 19 | } 20 | -------------------------------------------------------------------------------- /hscontrol/util/string_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGenerateRandomStringDNSSafe(t *testing.T) { 11 | for i := 0; i < 100000; i++ { 12 | str, err := GenerateRandomStringDNSSafe(8) 13 | require.NoError(t, err) 14 | assert.Len(t, str, 8) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /hscontrol/types/testdata/policy-path-is-loaded.yaml: -------------------------------------------------------------------------------- 1 | noise: 2 | private_key_path: "private_key.pem" 3 | 4 | prefixes: 5 | v6: fd7a:115c:a1e0::/48 6 | v4: 100.64.0.0/10 7 | 8 | database: 9 | type: sqlite3 10 | 11 | server_url: "https://derp.no" 12 | 13 | acl_policy_path: "/etc/acl_policy.yaml" 14 | policy: 15 | type: file 16 | path: "/etc/policy.hujson" 17 | 18 | dns.magic_dns: false 19 | -------------------------------------------------------------------------------- /hscontrol/types/const.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | const ( 6 | HTTPTimeout = 30 * time.Second 7 | HTTPShutdownTimeout = 3 * time.Second 8 | TLSALPN01ChallengeType = "TLS-ALPN-01" 9 | HTTP01ChallengeType = "HTTP-01" 10 | 11 | JSONLogFormat = "json" 12 | TextLogFormat = "text" 13 | 14 | KeepAliveInterval = 60 * time.Second 15 | MaxHostnameLength = 255 16 | ) 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | // integration tests are not needed in docker 2 | // ignoring it let us speed up the integration test 3 | // development 4 | integration_test.go 5 | integration_test/ 6 | !integration_test/etc_embedded_derp/tls/server.crt 7 | 8 | Dockerfile* 9 | docker-compose* 10 | .dockerignore 11 | .goreleaser.yml 12 | .git 13 | .github 14 | .gitignore 15 | README.md 16 | LICENSE 17 | .vscode 18 | 19 | *.sock 20 | -------------------------------------------------------------------------------- /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json 2 | language: "en-GB" 3 | early_access: false 4 | reviews: 5 | profile: "chill" 6 | request_changes_workflow: false 7 | high_level_summary: true 8 | poem: true 9 | review_status: true 10 | collapse_walkthrough: false 11 | auto_review: 12 | enabled: true 13 | drafts: true 14 | chat: 15 | auto_reply: true 16 | -------------------------------------------------------------------------------- /hscontrol/types/policy.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | var ( 10 | ErrPolicyNotFound = errors.New("acl policy not found") 11 | ErrPolicyUpdateIsDisabled = errors.New("update is disabled for modes other than 'database'") 12 | ) 13 | 14 | // Policy represents a policy in the database. 15 | type Policy struct { 16 | gorm.Model 17 | 18 | // Data contains the policy in HuJSON format. 19 | Data string 20 | } 21 | -------------------------------------------------------------------------------- /proto/buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | deps: 4 | - remote: buf.build 5 | owner: googleapis 6 | repository: googleapis 7 | commit: 62f35d8aed1149c291d606d958a7ce32 8 | - remote: buf.build 9 | owner: grpc-ecosystem 10 | repository: grpc-gateway 11 | commit: bc28b723cd774c32b6fbc77621518765 12 | - remote: buf.build 13 | owner: ufoundit-dev 14 | repository: protoc-gen-gorm 15 | commit: e2ecbaa0d37843298104bd29fd866df8 16 | -------------------------------------------------------------------------------- /docs/packaging/postremove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Determine OS platform 3 | # shellcheck source=/dev/null 4 | . /etc/os-release 5 | 6 | if command -V systemctl >/dev/null 2>&1; then 7 | echo "Stop and disable headscale service" 8 | systemctl stop headscale >/dev/null 2>&1 || true 9 | systemctl disable headscale >/dev/null 2>&1 || true 10 | echo "Running daemon-reload" 11 | systemctl daemon-reload || true 12 | fi 13 | 14 | echo "Removing run directory" 15 | rm -rf "/var/run/headscale.sock" 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Issues must have some content 2 | blank_issues_enabled: false 3 | 4 | # Contact links 5 | contact_links: 6 | - name: "headscale usage documentation" 7 | url: "https://github.com/juanfont/headscale/blob/main/docs" 8 | about: "Find documentation about how to configure and run headscale." 9 | - name: "headscale Discord community" 10 | url: "https://discord.gg/xGj2TuqyxY" 11 | about: "Please ask and answer questions about usage of headscale here." 12 | -------------------------------------------------------------------------------- /derp-example.yaml: -------------------------------------------------------------------------------- 1 | # If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/ 2 | regions: 3 | 900: 4 | regionid: 900 5 | regioncode: custom 6 | regionname: My Region 7 | nodes: 8 | - name: 900a 9 | regionid: 900 10 | hostname: myderp.mydomain.no 11 | ipv4: 123.123.123.123 12 | ipv6: "2604:a880:400:d1::828:b001" 13 | stunport: 0 14 | stunonly: false 15 | derpport: 0 16 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | plugins: 3 | - name: go 4 | out: gen/go 5 | opt: 6 | - paths=source_relative 7 | - name: go-grpc 8 | out: gen/go 9 | opt: 10 | - paths=source_relative 11 | - name: grpc-gateway 12 | out: gen/go 13 | opt: 14 | - paths=source_relative 15 | - generate_unbound_methods=true 16 | # - name: gorm 17 | # out: gen/go 18 | # opt: 19 | # - paths=source_relative,enums=string,gateway=true 20 | - name: openapiv2 21 | out: gen/openapiv2 22 | -------------------------------------------------------------------------------- /cmd/headscale/cli/version.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var Version = "dev" 8 | 9 | func init() { 10 | rootCmd.AddCommand(versionCmd) 11 | } 12 | 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Print the version.", 16 | Long: "The version of headscale.", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | output, _ := cmd.Flags().GetString("output") 19 | SuccessOutput(map[string]string{"version": Version}, Version, output) 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /proto/headscale/v1/policy.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | message SetPolicyRequest { string policy = 1; } 8 | 9 | message SetPolicyResponse { 10 | string policy = 1; 11 | google.protobuf.Timestamp updated_at = 2; 12 | } 13 | 14 | message GetPolicyRequest {} 15 | 16 | message GetPolicyResponse { 17 | string policy = 1; 18 | google.protobuf.Timestamp updated_at = 2; 19 | } 20 | -------------------------------------------------------------------------------- /cmd/headscale/cli/configtest.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func init() { 9 | rootCmd.AddCommand(configTestCmd) 10 | } 11 | 12 | var configTestCmd = &cobra.Command{ 13 | Use: "configtest", 14 | Short: "Test the configuration.", 15 | Long: "Run a test of the configuration and exit.", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | _, err := newHeadscaleServerWithConfig() 18 | if err != nil { 19 | log.Fatal().Caller().Err(err).Msg("Error initializing") 20 | } 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /docs/setup/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade an existing installation 2 | 3 | Update an existing headscale installation to a new version: 4 | 5 | - Read the announcement on the [GitHub releases](https://github.com/juanfont/headscale/releases) page for the new 6 | version. It lists the changes of the release along with possible breaking changes. 7 | - **Create a backup of your database.** 8 | - Update headscale to the new version, preferably by following the same installation method. 9 | - Compare and update the [configuration](../ref/configuration.md) file. 10 | - Restart headscale. 11 | -------------------------------------------------------------------------------- /Dockerfile.derper: -------------------------------------------------------------------------------- 1 | # For testing purposes only 2 | 3 | FROM golang:alpine AS build-env 4 | 5 | WORKDIR /go/src 6 | 7 | RUN apk add --no-cache git 8 | ARG VERSION_BRANCH=main 9 | RUN git clone https://github.com/tailscale/tailscale.git --branch=$VERSION_BRANCH --depth=1 10 | WORKDIR /go/src/tailscale 11 | 12 | ARG TARGETARCH 13 | RUN GOARCH=$TARGETARCH go install -v ./cmd/derper 14 | 15 | FROM alpine:3.18 16 | RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl 17 | 18 | COPY --from=build-env /go/bin/* /usr/local/bin/ 19 | ENTRYPOINT [ "/usr/local/bin/derper" ] 20 | -------------------------------------------------------------------------------- /docs/about/releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | All headscale releases are available on the [GitHub release page](https://github.com/juanfont/headscale/releases). Those 4 | releases are available as binaries for various platforms and architectures, packages for Debian based systems and source 5 | code archives. Container images are available on [Docker Hub](https://hub.docker.com/r/headscale/headscale). 6 | 7 | An Atom/RSS feed of headscale releases is available [here](https://github.com/juanfont/headscale/releases.atom). 8 | 9 | See the "announcements" channel on our [Discord server](https://discord.gg/c84AZQhmpx) for news about headscale. 10 | -------------------------------------------------------------------------------- /.github/workflows/update-flake.yml: -------------------------------------------------------------------------------- 1 | name: update-flake-lock 2 | on: 3 | workflow_dispatch: # allows manual triggering 4 | schedule: 5 | - cron: "0 0 * * 0" # runs weekly on Sunday at 00:00 6 | 7 | jobs: 8 | lockfile: 9 | if: github.repository == 'juanfont/headscale' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | - name: Install Nix 15 | uses: DeterminateSystems/nix-installer-action@main 16 | - name: Update flake.lock 17 | uses: DeterminateSystems/update-flake-lock@main 18 | with: 19 | pr-title: "Update flake.lock" 20 | -------------------------------------------------------------------------------- /cmd/headscale/cli/dump_config.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func init() { 11 | rootCmd.AddCommand(dumpConfigCmd) 12 | } 13 | 14 | var dumpConfigCmd = &cobra.Command{ 15 | Use: "dumpConfig", 16 | Short: "dump current config to /etc/headscale/config.dump.yaml, integration test only", 17 | Hidden: true, 18 | Args: func(cmd *cobra.Command, args []string) error { 19 | return nil 20 | }, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | err := viper.WriteConfigAs("/etc/headscale/config.dump.yaml") 23 | if err != nil { 24 | //nolint 25 | fmt.Println("Failed to dump config") 26 | } 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/gh-actions-updater.yaml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions Version Updater 2 | 3 | on: 4 | schedule: 5 | # Automatically run on every Sunday 6 | - cron: "0 0 * * 0" 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'juanfont/headscale' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | # [Required] Access token with `workflow` scope. 17 | token: ${{ secrets.WORKFLOW_SECRET }} 18 | 19 | - name: Run GitHub Actions Version Updater 20 | uses: saadmk11/github-actions-version-updater@v0.8.1 21 | with: 22 | # [Required] Access token with `workflow` scope. 23 | token: ${{ secrets.WORKFLOW_SECRET }} 24 | -------------------------------------------------------------------------------- /docs/usage/connect/android.md: -------------------------------------------------------------------------------- 1 | # Connecting an Android client 2 | 3 | This documentation has the goal of showing how a user can use the official Android [Tailscale](https://tailscale.com) client with headscale. 4 | 5 | ## Installation 6 | 7 | Install the official Tailscale Android client from the [Google Play Store](https://play.google.com/store/apps/details?id=com.tailscale.ipn) or [F-Droid](https://f-droid.org/packages/com.tailscale.ipn/). 8 | 9 | ## Configuring the headscale URL 10 | 11 | - Open the app and select the settings menu in the upper-right corner 12 | - Tap on `Accounts` 13 | - In the kebab menu icon (three dots) in the upper-right corner select `Use an alternate server` 14 | - Enter your server URL (e.g `https://headscale.example.com`) and follow the instructions 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ignored/ 2 | tailscale/ 3 | .vscode/ 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | vendor/ 20 | 21 | dist/ 22 | /headscale 23 | config.json 24 | config.yaml 25 | config*.yaml 26 | derp.yaml 27 | *.hujson 28 | *.key 29 | /db.sqlite 30 | *.sqlite3 31 | 32 | # Exclude Jetbrains Editors 33 | .idea 34 | 35 | test_output/ 36 | control_logs/ 37 | 38 | # Nix build output 39 | result 40 | .direnv/ 41 | 42 | integration_test/etc/config.dump.yaml 43 | 44 | # MkDocs 45 | .cache 46 | /site 47 | 48 | __debug_bin 49 | -------------------------------------------------------------------------------- /.github/workflows/docs-test.yml: -------------------------------------------------------------------------------- 1 | name: Test documentation build 2 | 3 | on: [pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | - name: Install python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.x 19 | - name: Setup cache 20 | uses: actions/cache@v4 21 | with: 22 | key: ${{ github.ref }} 23 | path: .cache 24 | - name: Setup dependencies 25 | run: pip install -r docs/requirements.txt 26 | - name: Build docs 27 | run: mkdocs build --strict 28 | -------------------------------------------------------------------------------- /cmd/headscale/cli/serve.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func init() { 12 | rootCmd.AddCommand(serveCmd) 13 | } 14 | 15 | var serveCmd = &cobra.Command{ 16 | Use: "serve", 17 | Short: "Launches the headscale server", 18 | Args: func(cmd *cobra.Command, args []string) error { 19 | return nil 20 | }, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | app, err := newHeadscaleServerWithConfig() 23 | if err != nil { 24 | log.Fatal().Caller().Err(err).Msg("Error initializing") 25 | } 26 | 27 | err = app.Serve() 28 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 29 | log.Fatal().Caller().Err(err).Msg("Headscale ran into an error and had to shut down.") 30 | } 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /integration/control.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1" 5 | "github.com/ory/dockertest/v3" 6 | ) 7 | 8 | type ControlServer interface { 9 | Shutdown() (string, string, error) 10 | SaveLog(string) (string, string, error) 11 | SaveProfile(string) error 12 | Execute(command []string) (string, error) 13 | WriteFile(path string, content []byte) error 14 | ConnectToNetwork(network *dockertest.Network) error 15 | GetHealthEndpoint() string 16 | GetEndpoint() string 17 | WaitForRunning() error 18 | CreateUser(user string) error 19 | CreateAuthKey(user string, reusable bool, ephemeral bool) (*v1.PreAuthKey, error) 20 | ListNodes(users ...string) ([]*v1.Node, error) 21 | ListUsers() ([]*v1.User, error) 22 | GetCert() []byte 23 | GetHostname() string 24 | GetIP() string 25 | } 26 | -------------------------------------------------------------------------------- /hscontrol/types/testdata/dns_full.yaml: -------------------------------------------------------------------------------- 1 | # minimum to not fatal 2 | noise: 3 | private_key_path: "private_key.pem" 4 | server_url: "https://derp.no" 5 | 6 | dns: 7 | magic_dns: true 8 | base_domain: example.com 9 | 10 | nameservers: 11 | global: 12 | - 1.1.1.1 13 | - 1.0.0.1 14 | - 2606:4700:4700::1111 15 | - 2606:4700:4700::1001 16 | - https://dns.nextdns.io/abc123 17 | 18 | split: 19 | foo.bar.com: 20 | - 1.1.1.1 21 | darp.headscale.net: 22 | - 1.1.1.1 23 | - 8.8.8.8 24 | 25 | search_domains: 26 | - test.com 27 | - bar.com 28 | 29 | extra_records: 30 | - name: "grafana.myvpn.example.com" 31 | type: "A" 32 | value: "100.64.0.3" 33 | 34 | # you can also put it in one line 35 | - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.4" } 36 | -------------------------------------------------------------------------------- /hscontrol/types/testdata/dns_full_no_magic.yaml: -------------------------------------------------------------------------------- 1 | # minimum to not fatal 2 | noise: 3 | private_key_path: "private_key.pem" 4 | server_url: "https://derp.no" 5 | 6 | dns: 7 | magic_dns: false 8 | base_domain: example.com 9 | 10 | nameservers: 11 | global: 12 | - 1.1.1.1 13 | - 1.0.0.1 14 | - 2606:4700:4700::1111 15 | - 2606:4700:4700::1001 16 | - https://dns.nextdns.io/abc123 17 | 18 | split: 19 | foo.bar.com: 20 | - 1.1.1.1 21 | darp.headscale.net: 22 | - 1.1.1.1 23 | - 8.8.8.8 24 | 25 | search_domains: 26 | - test.com 27 | - bar.com 28 | 29 | extra_records: 30 | - name: "grafana.myvpn.example.com" 31 | type: "A" 32 | value: "100.64.0.3" 33 | 34 | # you can also put it in one line 35 | - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.4" } 36 | -------------------------------------------------------------------------------- /proto/headscale/v1/apikey.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | message ApiKey { 8 | uint64 id = 1; 9 | string prefix = 2; 10 | google.protobuf.Timestamp expiration = 3; 11 | google.protobuf.Timestamp created_at = 4; 12 | google.protobuf.Timestamp last_seen = 5; 13 | } 14 | 15 | message CreateApiKeyRequest { google.protobuf.Timestamp expiration = 1; } 16 | 17 | message CreateApiKeyResponse { string api_key = 1; } 18 | 19 | message ExpireApiKeyRequest { string prefix = 1; } 20 | 21 | message ExpireApiKeyResponse {} 22 | 23 | message ListApiKeysRequest {} 24 | 25 | message ListApiKeysResponse { repeated ApiKey api_keys = 1; } 26 | 27 | message DeleteApiKeyRequest { string prefix = 1; } 28 | 29 | message DeleteApiKeyResponse {} 30 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | close-issues: 9 | if: github.repository == 'juanfont/headscale' 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/stale@v9 16 | with: 17 | days-before-issue-stale: 90 18 | days-before-issue-close: 7 19 | stale-issue-label: "stale" 20 | stale-issue-message: "This issue is stale because it has been open for 90 days with no activity." 21 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 22 | days-before-pr-stale: -1 23 | days-before-pr-close: -1 24 | exempt-issue-labels: "no-stale-bot" 25 | repo-token: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /docs/ref/integration/tools.md: -------------------------------------------------------------------------------- 1 | # Tools related to headscale 2 | 3 | !!! warning "Community contributions" 4 | 5 | This page contains community contributions. The projects listed here are not 6 | maintained by the headscale authors and are written by community members. 7 | 8 | This page collects third-party tools and scripts related to headscale. 9 | 10 | | Name | Repository Link | Description | 11 | | --------------------- | --------------------------------------------------------------- | ------------------------------------------------- | 12 | | tailscale-manager | [Github](https://github.com/singlestore-labs/tailscale-manager) | Dynamically manage Tailscale route advertisements | 13 | | headscalebacktosqlite | [Github](https://github.com/bigbozza/headscalebacktosqlite) | Migrate headscale from PostgreSQL back to SQLite | 14 | -------------------------------------------------------------------------------- /Dockerfile.integration: -------------------------------------------------------------------------------- 1 | # This Dockerfile and the images produced are for testing headscale, 2 | # and are in no way endorsed by Headscale's maintainers as an 3 | # official nor supported release or distribution. 4 | 5 | FROM docker.io/golang:1.23-bookworm 6 | ARG VERSION=dev 7 | ENV GOPATH /go 8 | WORKDIR /go/src/headscale 9 | 10 | RUN apt-get update \ 11 | && apt-get install --no-install-recommends --yes less jq sqlite3 dnsutils \ 12 | && rm -rf /var/lib/apt/lists/* \ 13 | && apt-get clean 14 | RUN mkdir -p /var/run/headscale 15 | 16 | COPY go.mod go.sum /go/src/headscale/ 17 | RUN go mod download 18 | 19 | COPY . . 20 | 21 | RUN CGO_ENABLED=0 GOOS=linux go install -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$VERSION" -a ./cmd/headscale && test -e /go/bin/headscale 22 | 23 | # Need to reset the entrypoint or everything will run as a busybox script 24 | ENTRYPOINT [] 25 | EXPOSE 8080/tcp 26 | CMD ["headscale"] 27 | -------------------------------------------------------------------------------- /hscontrol/grpcv1_test.go: -------------------------------------------------------------------------------- 1 | package hscontrol 2 | 3 | import "testing" 4 | 5 | func Test_validateTag(t *testing.T) { 6 | type args struct { 7 | tag string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | wantErr bool 13 | }{ 14 | { 15 | name: "valid tag", 16 | args: args{tag: "tag:test"}, 17 | wantErr: false, 18 | }, 19 | { 20 | name: "tag without tag prefix", 21 | args: args{tag: "test"}, 22 | wantErr: true, 23 | }, 24 | { 25 | name: "uppercase tag", 26 | args: args{tag: "tag:tEST"}, 27 | wantErr: true, 28 | }, 29 | { 30 | name: "tag that contains space", 31 | args: args{tag: "tag:this is a spaced tag"}, 32 | wantErr: true, 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | if err := validateTag(tt.args.tag); (err != nil) != tt.wantErr { 38 | t.Errorf("validateTag() error = %v, wantErr %v", err, tt.wantErr) 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | - [ ] have read the [CONTRIBUTING.md](./CONTRIBUTING.md) file 16 | - [ ] raised a GitHub issue or discussed it on the projects chat beforehand 17 | - [ ] added unit tests 18 | - [ ] added integration tests 19 | - [ ] updated documentation if needed 20 | - [ ] updated CHANGELOG.md 21 | 22 | 23 | -------------------------------------------------------------------------------- /hscontrol/types/api_key.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1" 7 | "google.golang.org/protobuf/types/known/timestamppb" 8 | ) 9 | 10 | // APIKey describes the datamodel for API keys used to remotely authenticate with 11 | // headscale. 12 | type APIKey struct { 13 | ID uint64 `gorm:"primary_key"` 14 | Prefix string `gorm:"uniqueIndex"` 15 | Hash []byte 16 | 17 | CreatedAt *time.Time 18 | Expiration *time.Time 19 | LastSeen *time.Time 20 | } 21 | 22 | func (key *APIKey) Proto() *v1.ApiKey { 23 | protoKey := v1.ApiKey{ 24 | Id: key.ID, 25 | Prefix: key.Prefix, 26 | } 27 | 28 | if key.Expiration != nil { 29 | protoKey.Expiration = timestamppb.New(*key.Expiration) 30 | } 31 | 32 | if key.CreatedAt != nil { 33 | protoKey.CreatedAt = timestamppb.New(*key.CreatedAt) 34 | } 35 | 36 | if key.LastSeen != nil { 37 | protoKey.LastSeen = timestamppb.New(*key.LastSeen) 38 | } 39 | 40 | return &protoKey 41 | } 42 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/node.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/node.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/user.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/user.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/apikey.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/apikey.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/device.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/device.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/policy.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/policy.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/routes.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/routes.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gen/openapiv2/headscale/v1/preauthkey.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "headscale/v1/preauthkey.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": {}, 14 | "definitions": { 15 | "protobufAny": { 16 | "type": "object", 17 | "properties": { 18 | "@type": { 19 | "type": "string" 20 | } 21 | }, 22 | "additionalProperties": {} 23 | }, 24 | "rpcStatus": { 25 | "type": "object", 26 | "properties": { 27 | "code": { 28 | "type": "integer", 29 | "format": "int32" 30 | }, 31 | "message": { 32 | "type": "string" 33 | }, 34 | "details": { 35 | "type": "array", 36 | "items": { 37 | "type": "object", 38 | "$ref": "#/definitions/protobufAny" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /hscontrol/db/policy.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/juanfont/headscale/hscontrol/types" 7 | "gorm.io/gorm" 8 | "gorm.io/gorm/clause" 9 | ) 10 | 11 | // SetPolicy sets the policy in the database. 12 | func (hsdb *HSDatabase) SetPolicy(policy string) (*types.Policy, error) { 13 | // Create a new policy. 14 | p := types.Policy{ 15 | Data: policy, 16 | } 17 | 18 | if err := hsdb.DB.Clauses(clause.Returning{}).Create(&p).Error; err != nil { 19 | return nil, err 20 | } 21 | 22 | return &p, nil 23 | } 24 | 25 | // GetPolicy returns the latest policy in the database. 26 | func (hsdb *HSDatabase) GetPolicy() (*types.Policy, error) { 27 | var p types.Policy 28 | 29 | // Query: 30 | // SELECT * FROM policies ORDER BY id DESC LIMIT 1; 31 | if err := hsdb.DB. 32 | Order("id DESC"). 33 | Limit(1). 34 | First(&p).Error; err != nil { 35 | if errors.Is(err, gorm.ErrRecordNotFound) { 36 | return nil, types.ErrPolicyNotFound 37 | } 38 | 39 | return nil, err 40 | } 41 | 42 | return &p, nil 43 | } 44 | -------------------------------------------------------------------------------- /cmd/headscale/cli/generate.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "tailscale.com/types/key" 8 | ) 9 | 10 | func init() { 11 | rootCmd.AddCommand(generateCmd) 12 | generateCmd.AddCommand(generatePrivateKeyCmd) 13 | } 14 | 15 | var generateCmd = &cobra.Command{ 16 | Use: "generate", 17 | Short: "Generate commands", 18 | Aliases: []string{"gen"}, 19 | } 20 | 21 | var generatePrivateKeyCmd = &cobra.Command{ 22 | Use: "private-key", 23 | Short: "Generate a private key for the headscale server", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | output, _ := cmd.Flags().GetString("output") 26 | machineKey := key.NewMachine() 27 | 28 | machineKeyStr, err := machineKey.MarshalText() 29 | if err != nil { 30 | ErrorOutput( 31 | err, 32 | fmt.Sprintf("Error getting machine key from flag: %s", err), 33 | output, 34 | ) 35 | } 36 | 37 | SuccessOutput(map[string]string{ 38 | "private_key": string(machineKeyStr), 39 | }, 40 | string(machineKeyStr), output) 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration testing 2 | 3 | Headscale relies on integration testing to ensure we remain compatible with Tailscale. 4 | 5 | This is typically performed by starting a Headscale server and running a test "scenario" 6 | with an array of Tailscale clients and versions. 7 | 8 | Headscale's test framework and the current set of scenarios are defined in this directory. 9 | 10 | Tests are located in files ending with `_test.go` and the framework are located in the rest. 11 | 12 | ## Running integration tests locally 13 | 14 | The easiest way to run tests locally is to use [act](https://github.com/nektos/act), a local GitHub Actions runner: 15 | 16 | ``` 17 | act pull_request -W .github/workflows/test-integration.yaml 18 | ``` 19 | 20 | Alternatively, the `docker run` command in each GitHub workflow file can be used. 21 | 22 | ## Running integration tests on GitHub Actions 23 | 24 | Each test currently runs as a separate workflows in GitHub actions, to add new test, run 25 | `go generate` inside `../cmd/gh-action-integration-generator/` and commit the result. 26 | -------------------------------------------------------------------------------- /proto/headscale/v1/preauthkey.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | message PreAuthKey { 8 | string user = 1; 9 | string id = 2; 10 | string key = 3; 11 | bool reusable = 4; 12 | bool ephemeral = 5; 13 | bool used = 6; 14 | google.protobuf.Timestamp expiration = 7; 15 | google.protobuf.Timestamp created_at = 8; 16 | repeated string acl_tags = 9; 17 | } 18 | 19 | message CreatePreAuthKeyRequest { 20 | string user = 1; 21 | bool reusable = 2; 22 | bool ephemeral = 3; 23 | google.protobuf.Timestamp expiration = 4; 24 | repeated string acl_tags = 5; 25 | } 26 | 27 | message CreatePreAuthKeyResponse { PreAuthKey pre_auth_key = 1; } 28 | 29 | message ExpirePreAuthKeyRequest { 30 | string user = 1; 31 | string key = 2; 32 | } 33 | 34 | message ExpirePreAuthKeyResponse {} 35 | 36 | message ListPreAuthKeysRequest { string user = 1; } 37 | 38 | message ListPreAuthKeysResponse { repeated PreAuthKey pre_auth_keys = 1; } 39 | -------------------------------------------------------------------------------- /cmd/headscale/headscale.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/jagottsicher/termcolor" 8 | "github.com/juanfont/headscale/cmd/headscale/cli" 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | func main() { 14 | var colors bool 15 | switch l := termcolor.SupportLevel(os.Stderr); l { 16 | case termcolor.Level16M: 17 | colors = true 18 | case termcolor.Level256: 19 | colors = true 20 | case termcolor.LevelBasic: 21 | colors = true 22 | case termcolor.LevelNone: 23 | colors = false 24 | default: 25 | // no color, return text as is. 26 | colors = false 27 | } 28 | 29 | // Adhere to no-color.org manifesto of allowing users to 30 | // turn off color in cli/services 31 | if _, noColorIsSet := os.LookupEnv("NO_COLOR"); noColorIsSet { 32 | colors = false 33 | } 34 | 35 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 36 | log.Logger = log.Output(zerolog.ConsoleWriter{ 37 | Out: os.Stderr, 38 | TimeFormat: time.RFC3339, 39 | NoColor: !colors, 40 | }) 41 | 42 | cli.Execute() 43 | } 44 | -------------------------------------------------------------------------------- /proto/headscale/v1/user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | message User { 8 | uint64 id = 1; 9 | string name = 2; 10 | google.protobuf.Timestamp created_at = 3; 11 | string display_name = 4; 12 | string email = 5; 13 | string provider_id = 6; 14 | string provider = 7; 15 | string profile_pic_url = 8; 16 | } 17 | 18 | message CreateUserRequest { 19 | string name = 1; 20 | string display_name = 2; 21 | string email = 3; 22 | string picture_url = 4; 23 | } 24 | 25 | message CreateUserResponse { User user = 1; } 26 | 27 | message RenameUserRequest { 28 | uint64 old_id = 1; 29 | string new_name = 2; 30 | } 31 | 32 | message RenameUserResponse { User user = 1; } 33 | 34 | message DeleteUserRequest { uint64 id = 1; } 35 | 36 | message DeleteUserResponse {} 37 | 38 | message ListUsersRequest { 39 | uint64 id = 1; 40 | string name = 2; 41 | string email = 3; 42 | } 43 | 44 | message ListUsersResponse { repeated User users = 1; } 45 | -------------------------------------------------------------------------------- /hscontrol/templates/windows.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chasefleming/elem-go" 7 | "github.com/chasefleming/elem-go/attrs" 8 | ) 9 | 10 | func Windows(url string) *elem.Element { 11 | return HtmlStructure( 12 | elem.Title(nil, 13 | elem.Text("headscale - Windows"), 14 | ), 15 | elem.Body(attrs.Props{ 16 | attrs.Style: bodyStyle.ToInline(), 17 | }, 18 | headerOne("headscale: Windows configuration"), 19 | elem.P(nil, 20 | elem.Text("Download "), 21 | elem.A(attrs.Props{ 22 | attrs.Href: "https://tailscale.com/download/windows", 23 | attrs.Rel: "noreferrer noopener", 24 | attrs.Target: "_blank", 25 | }, 26 | elem.Text("Tailscale for Windows ")), 27 | elem.Text("and install it."), 28 | ), 29 | elem.P(nil, 30 | elem.Text("Open a Command Prompt or Powershell and use Tailscale's login command to connect with headscale: "), 31 | ), 32 | elem.Pre(nil, 33 | elem.Code(nil, 34 | elem.Text(fmt.Sprintf(`tailscale login --login-server %s`, url)), 35 | ), 36 | ), 37 | ), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /proto/headscale/v1/routes.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | import "headscale/v1/node.proto"; 7 | 8 | message Route { 9 | uint64 id = 1; 10 | Node node = 2; 11 | string prefix = 3; 12 | bool advertised = 4; 13 | bool enabled = 5; 14 | bool is_primary = 6; 15 | 16 | google.protobuf.Timestamp created_at = 7; 17 | google.protobuf.Timestamp updated_at = 8; 18 | google.protobuf.Timestamp deleted_at = 9; 19 | } 20 | 21 | message GetRoutesRequest {} 22 | 23 | message GetRoutesResponse { repeated Route routes = 1; } 24 | 25 | message EnableRouteRequest { uint64 route_id = 1; } 26 | 27 | message EnableRouteResponse {} 28 | 29 | message DisableRouteRequest { uint64 route_id = 1; } 30 | 31 | message DisableRouteResponse {} 32 | 33 | message GetNodeRoutesRequest { uint64 node_id = 1; } 34 | 35 | message GetNodeRoutesResponse { repeated Route routes = 1; } 36 | 37 | message DeleteRouteRequest { uint64 route_id = 1; } 38 | 39 | message DeleteRouteResponse {} 40 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseBranches": ["main"], 3 | "username": "renovate-release", 4 | "gitAuthor": "Renovate Bot ", 5 | "branchPrefix": "renovateaction/", 6 | "onboarding": false, 7 | "extends": ["config:base", ":rebaseStalePrs"], 8 | "ignorePresets": [":prHourlyLimit2"], 9 | "enabledManagers": ["dockerfile", "gomod", "github-actions", "regex"], 10 | "includeForks": true, 11 | "repositories": ["juanfont/headscale"], 12 | "platform": "github", 13 | "packageRules": [ 14 | { 15 | "matchDatasources": ["go"], 16 | "groupName": "Go modules", 17 | "groupSlug": "gomod", 18 | "separateMajorMinor": false 19 | }, 20 | { 21 | "matchDatasources": ["docker"], 22 | "groupName": "Dockerfiles", 23 | "groupSlug": "dockerfiles" 24 | } 25 | ], 26 | "regexManagers": [ 27 | { 28 | "fileMatch": [".github/workflows/.*.yml$"], 29 | "matchStrings": ["\\s*go-version:\\s*\"?(?.*?)\"?\\n"], 30 | "datasourceTemplate": "golang-version", 31 | "depNameTemplate": "actions/go-version" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /hscontrol/suite_test.go: -------------------------------------------------------------------------------- 1 | package hscontrol 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/juanfont/headscale/hscontrol/types" 8 | "gopkg.in/check.v1" 9 | ) 10 | 11 | func Test(t *testing.T) { 12 | check.TestingT(t) 13 | } 14 | 15 | var _ = check.Suite(&Suite{}) 16 | 17 | type Suite struct{} 18 | 19 | var ( 20 | tmpDir string 21 | app *Headscale 22 | ) 23 | 24 | func (s *Suite) SetUpTest(c *check.C) { 25 | s.ResetDB(c) 26 | } 27 | 28 | func (s *Suite) TearDownTest(c *check.C) { 29 | os.RemoveAll(tmpDir) 30 | } 31 | 32 | func (s *Suite) ResetDB(c *check.C) { 33 | if len(tmpDir) != 0 { 34 | os.RemoveAll(tmpDir) 35 | } 36 | var err error 37 | tmpDir, err = os.MkdirTemp("", "autoygg-client-test2") 38 | if err != nil { 39 | c.Fatal(err) 40 | } 41 | cfg := types.Config{ 42 | NoisePrivateKeyPath: tmpDir + "/noise_private.key", 43 | Database: types.DatabaseConfig{ 44 | Type: "sqlite3", 45 | Sqlite: types.SqliteConfig{ 46 | Path: tmpDir + "/headscale_test.db", 47 | }, 48 | }, 49 | OIDC: types.OIDCConfig{}, 50 | } 51 | 52 | app, err = NewHeadscale(&cfg) 53 | if err != nil { 54 | c.Fatal(err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - "*" # triggers only if push new tag version 8 | workflow_dispatch: 9 | 10 | jobs: 11 | goreleaser: 12 | if: github.repository == 'juanfont/headscale' 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Login to DockerHub 21 | uses: docker/login-action@v3 22 | with: 23 | username: ${{ secrets.DOCKERHUB_USERNAME }} 24 | password: ${{ secrets.DOCKERHUB_TOKEN }} 25 | 26 | - name: Login to GHCR 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.repository_owner }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - uses: DeterminateSystems/nix-installer-action@main 34 | - uses: DeterminateSystems/magic-nix-cache-action@main 35 | 36 | - name: Run goreleaser 37 | run: nix develop --command -- goreleaser release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /hscontrol/util/test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/netip" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "tailscale.com/types/ipproto" 8 | "tailscale.com/types/key" 9 | "tailscale.com/types/views" 10 | ) 11 | 12 | var PrefixComparer = cmp.Comparer(func(x, y netip.Prefix) bool { 13 | return x == y 14 | }) 15 | 16 | var IPComparer = cmp.Comparer(func(x, y netip.Addr) bool { 17 | return x.Compare(y) == 0 18 | }) 19 | 20 | var AddrPortComparer = cmp.Comparer(func(x, y netip.AddrPort) bool { 21 | return x == y 22 | }) 23 | 24 | var MkeyComparer = cmp.Comparer(func(x, y key.MachinePublic) bool { 25 | return x.String() == y.String() 26 | }) 27 | 28 | var NkeyComparer = cmp.Comparer(func(x, y key.NodePublic) bool { 29 | return x.String() == y.String() 30 | }) 31 | 32 | var DkeyComparer = cmp.Comparer(func(x, y key.DiscoPublic) bool { 33 | return x.String() == y.String() 34 | }) 35 | 36 | var ViewSliceIPProtoComparer = cmp.Comparer(func(a, b views.Slice[ipproto.Proto]) bool { return views.SliceEqual(a, b) }) 37 | 38 | var Comparers []cmp.Option = []cmp.Option{ 39 | IPComparer, PrefixComparer, AddrPortComparer, MkeyComparer, NkeyComparer, DkeyComparer, ViewSliceIPProtoComparer, 40 | } 41 | -------------------------------------------------------------------------------- /hscontrol/capver/capver_generated.go: -------------------------------------------------------------------------------- 1 | package capver 2 | 3 | //Generated DO NOT EDIT 4 | 5 | import "tailscale.com/tailcfg" 6 | 7 | var tailscaleToCapVer = map[string]tailcfg.CapabilityVersion{ 8 | "v1.44.3": 63, 9 | "v1.56.1": 82, 10 | "v1.58.0": 85, 11 | "v1.58.1": 85, 12 | "v1.58.2": 85, 13 | "v1.60.0": 87, 14 | "v1.60.1": 87, 15 | "v1.62.0": 88, 16 | "v1.62.1": 88, 17 | "v1.64.0": 90, 18 | "v1.64.1": 90, 19 | "v1.64.2": 90, 20 | "v1.66.0": 95, 21 | "v1.66.1": 95, 22 | "v1.66.2": 95, 23 | "v1.66.3": 95, 24 | "v1.66.4": 95, 25 | "v1.68.0": 97, 26 | "v1.68.1": 97, 27 | "v1.68.2": 97, 28 | "v1.70.0": 102, 29 | "v1.72.0": 104, 30 | "v1.72.1": 104, 31 | "v1.74.0": 106, 32 | "v1.74.1": 106, 33 | "v1.76.0": 106, 34 | "v1.76.1": 106, 35 | "v1.76.6": 106, 36 | "v1.78.0": 109, 37 | "v1.78.1": 109, 38 | "v1.80.0": 113, 39 | } 40 | 41 | 42 | var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{ 43 | 63: "v1.44.3", 44 | 82: "v1.56.1", 45 | 85: "v1.58.0", 46 | 87: "v1.60.0", 47 | 88: "v1.62.0", 48 | 90: "v1.64.0", 49 | 95: "v1.66.0", 50 | 97: "v1.68.0", 51 | 102: "v1.70.0", 52 | 104: "v1.72.0", 53 | 106: "v1.74.0", 54 | 109: "v1.78.0", 55 | 113: "v1.80.0", 56 | } 57 | -------------------------------------------------------------------------------- /docs/logo/headscale3-dots.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hscontrol/templates/register_web.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chasefleming/elem-go" 7 | "github.com/chasefleming/elem-go/attrs" 8 | "github.com/chasefleming/elem-go/styles" 9 | "github.com/juanfont/headscale/hscontrol/types" 10 | ) 11 | 12 | var codeStyleRegisterWebAPI = styles.Props{ 13 | styles.Display: "block", 14 | styles.Padding: "20px", 15 | styles.Border: "1px solid #bbb", 16 | styles.BackgroundColor: "#eee", 17 | } 18 | 19 | func RegisterWeb(registrationID types.RegistrationID) *elem.Element { 20 | return HtmlStructure( 21 | elem.Title(nil, elem.Text("Registration - Headscale")), 22 | elem.Body(attrs.Props{ 23 | attrs.Style: styles.Props{ 24 | styles.FontFamily: "sans", 25 | }.ToInline(), 26 | }, 27 | elem.H1(nil, elem.Text("headscale")), 28 | elem.H2(nil, elem.Text("Machine registration")), 29 | elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network: ")), 30 | elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()}, 31 | elem.Text(fmt.Sprintf("headscale nodes register --user USERNAME --key %s", registrationID.String())), 32 | ), 33 | ), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /hscontrol/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "tailscale.com/util/cmpver" 10 | ) 11 | 12 | func TailscaleVersionNewerOrEqual(minimum, toCheck string) bool { 13 | if cmpver.Compare(minimum, toCheck) <= 0 || 14 | toCheck == "unstable" || 15 | toCheck == "head" { 16 | return true 17 | } 18 | 19 | return false 20 | } 21 | 22 | // ParseLoginURLFromCLILogin parses the output of the tailscale up command to extract the login URL. 23 | // It returns an error if not exactly one URL is found. 24 | func ParseLoginURLFromCLILogin(output string) (*url.URL, error) { 25 | lines := strings.Split(output, "\n") 26 | var urlStr string 27 | 28 | for _, line := range lines { 29 | line = strings.TrimSpace(line) 30 | if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") { 31 | if urlStr != "" { 32 | return nil, fmt.Errorf("multiple URLs found: %s and %s", urlStr, line) 33 | } 34 | urlStr = line 35 | } 36 | } 37 | 38 | if urlStr == "" { 39 | return nil, errors.New("no URL found") 40 | } 41 | 42 | loginURL, err := url.Parse(urlStr) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to parse URL: %w", err) 45 | } 46 | 47 | return loginURL, nil 48 | } 49 | -------------------------------------------------------------------------------- /hscontrol/types/preauth_key.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1" 8 | "github.com/juanfont/headscale/hscontrol/util" 9 | "google.golang.org/protobuf/types/known/timestamppb" 10 | ) 11 | 12 | // PreAuthKey describes a pre-authorization key usable in a particular user. 13 | type PreAuthKey struct { 14 | ID uint64 `gorm:"primary_key"` 15 | Key string 16 | UserID uint 17 | User User `gorm:"constraint:OnDelete:SET NULL;"` 18 | Reusable bool 19 | Ephemeral bool `gorm:"default:false"` 20 | Used bool `gorm:"default:false"` 21 | Tags []string `gorm:"serializer:json"` 22 | 23 | CreatedAt *time.Time 24 | Expiration *time.Time 25 | } 26 | 27 | func (key *PreAuthKey) Proto() *v1.PreAuthKey { 28 | protoKey := v1.PreAuthKey{ 29 | User: key.User.Username(), 30 | Id: strconv.FormatUint(key.ID, util.Base10), 31 | Key: key.Key, 32 | Ephemeral: key.Ephemeral, 33 | Reusable: key.Reusable, 34 | Used: key.Used, 35 | AclTags: key.Tags, 36 | } 37 | 38 | if key.Expiration != nil { 39 | protoKey.Expiration = timestamppb.New(*key.Expiration) 40 | } 41 | 42 | if key.CreatedAt != nil { 43 | protoKey.CreatedAt = timestamppb.New(*key.CreatedAt) 44 | } 45 | 46 | return &protoKey 47 | } 48 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 | # Welcome to headscale 8 | 9 | Headscale is an open source, self-hosted implementation of the Tailscale control server. 10 | 11 | This page contains the documentation for the latest version of headscale. Please also check our [FAQ](./about/faq.md). 12 | 13 | Join our [Discord server](https://discord.gg/c84AZQhmpx) for a chat and community support. 14 | 15 | ## Design goal 16 | 17 | Headscale aims to implement a self-hosted, open source alternative to the 18 | [Tailscale](https://tailscale.com/) control server. Headscale's goal is to 19 | provide self-hosters and hobbyists with an open-source server they can use for 20 | their projects and labs. It implements a narrow scope, a _single_ Tailscale 21 | network (tailnet), suitable for a personal use, or a small open-source 22 | organisation. 23 | 24 | ## Supporting headscale 25 | 26 | Please see [Sponsor](about/sponsor.md) for more information. 27 | 28 | ## Contributing 29 | 30 | Headscale is "Open Source, acknowledged contribution", this means that any 31 | contribution will have to be discussed with the Maintainers before being submitted. 32 | 33 | Please see [Contributing](about/contributing.md) for more information. 34 | 35 | ## About 36 | 37 | Headscale is maintained by [Kristoffer Dalby](https://kradalby.no/) and [Juan Font](https://font.eu). 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea for Headscale 3 | title: "[Feature] " 4 | labels: [enhancement] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Use case 9 | description: Please describe the use case for this feature. 10 | placeholder: | 11 | <!-- Include the reason, why you would need the feature. E.g. what problem 12 | does it solve? Or which workflow is currently frustrating and will be improved by 13 | this? --> 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Description 19 | description: A clear and precise description of what new or changed feature you want. 20 | validations: 21 | required: true 22 | - type: checkboxes 23 | attributes: 24 | label: Contribution 25 | description: Are you willing to contribute to the implementation of this feature? 26 | options: 27 | - label: I can write the design doc for this feature 28 | required: false 29 | - label: I can contribute this feature 30 | required: false 31 | - type: textarea 32 | attributes: 33 | label: How can it be implemented? 34 | description: Free text for your ideas on how this feature could be implemented. 35 | validations: 36 | required: false 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 2 17 | 18 | - name: Get changed files 19 | id: changed-files 20 | uses: dorny/paths-filter@v3 21 | with: 22 | filters: | 23 | files: 24 | - '*.nix' 25 | - 'go.*' 26 | - '**/*.go' 27 | - 'integration_test/' 28 | - 'config-example.yaml' 29 | 30 | - uses: DeterminateSystems/nix-installer-action@main 31 | if: steps.changed-files.outputs.files == 'true' 32 | - uses: DeterminateSystems/magic-nix-cache-action@main 33 | if: steps.changed-files.outputs.files == 'true' 34 | 35 | - name: Run tests 36 | if: steps.changed-files.outputs.files == 'true' 37 | env: 38 | # As of 2025-01-06, these env vars was not automatically 39 | # set anymore which breaks the initdb for postgres on 40 | # some of the database migration tests. 41 | LC_ALL: "en_US.UTF-8" 42 | LC_CTYPE: "en_US.UTF-8" 43 | run: nix develop --command -- gotestsum 44 | -------------------------------------------------------------------------------- /.github/workflows/check-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Check integration tests workflow 2 | 3 | on: [pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | check-tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 2 16 | - name: Get changed files 17 | id: changed-files 18 | uses: dorny/paths-filter@v3 19 | with: 20 | filters: | 21 | files: 22 | - '*.nix' 23 | - 'go.*' 24 | - '**/*.go' 25 | - 'integration_test/' 26 | - 'config-example.yaml' 27 | - uses: DeterminateSystems/nix-installer-action@main 28 | if: steps.changed-files.outputs.files == 'true' 29 | - uses: DeterminateSystems/magic-nix-cache-action@main 30 | if: steps.changed-files.outputs.files == 'true' 31 | 32 | - name: Generate and check integration tests 33 | if: steps.changed-files.outputs.files == 'true' 34 | run: | 35 | nix develop --command bash -c "cd .github/workflows && go generate" 36 | git diff --exit-code .github/workflows/test-integration.yaml 37 | 38 | - name: Show missing tests 39 | if: failure() 40 | run: | 41 | git diff .github/workflows/test-integration.yaml 42 | -------------------------------------------------------------------------------- /integration/dockertestutil/config.go: -------------------------------------------------------------------------------- 1 | package dockertestutil 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/ory/dockertest/v3/docker" 7 | ) 8 | 9 | func IsRunningInContainer() bool { 10 | if _, err := os.Stat("/.dockerenv"); err != nil { 11 | return false 12 | } 13 | 14 | return true 15 | } 16 | 17 | func DockerRestartPolicy(config *docker.HostConfig) { 18 | // set AutoRemove to true so that stopped container goes away by itself on error *immediately*. 19 | // when set to false, containers remain until the end of the integration test. 20 | config.AutoRemove = false 21 | config.RestartPolicy = docker.RestartPolicy{ 22 | Name: "no", 23 | } 24 | } 25 | 26 | func DockerAllowLocalIPv6(config *docker.HostConfig) { 27 | if config.Sysctls == nil { 28 | config.Sysctls = make(map[string]string, 1) 29 | } 30 | config.Sysctls["net.ipv6.conf.all.disable_ipv6"] = "0" 31 | } 32 | 33 | func DockerAllowNetworkAdministration(config *docker.HostConfig) { 34 | // Needed since containerd (1.7.24) 35 | // https://github.com/tailscale/tailscale/issues/14256 36 | // https://github.com/opencontainers/runc/commit/2ce40b6ad72b4bd4391380cafc5ef1bad1fa0b31 37 | config.CapAdd = append(config.CapAdd, "NET_ADMIN") 38 | config.CapAdd = append(config.CapAdd, "NET_RAW") 39 | config.Devices = append(config.Devices, docker.Device{ 40 | PathOnHost: "/dev/net/tun", 41 | PathInContainer: "/dev/net/tun", 42 | CgroupPermissions: "rwm", 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /docs/packaging/headscale.systemd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=syslog.target 3 | After=network.target 4 | Description=headscale coordination server for Tailscale 5 | X-Restart-Triggers=/etc/headscale/config.yaml 6 | 7 | [Service] 8 | Type=simple 9 | User=headscale 10 | Group=headscale 11 | ExecStart=/usr/bin/headscale serve 12 | ExecReload=/usr/bin/kill -HUP $MAINPID 13 | Restart=always 14 | RestartSec=5 15 | 16 | WorkingDirectory=/var/lib/headscale 17 | ReadWritePaths=/var/lib/headscale /var/run 18 | 19 | AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_CHOWN 20 | CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN 21 | LockPersonality=true 22 | NoNewPrivileges=true 23 | PrivateDevices=true 24 | PrivateMounts=true 25 | PrivateTmp=true 26 | ProcSubset=pid 27 | ProtectClock=true 28 | ProtectControlGroups=true 29 | ProtectHome=true 30 | ProtectHostname=true 31 | ProtectKernelLogs=true 32 | ProtectKernelModules=true 33 | ProtectKernelTunables=true 34 | ProtectProc=invisible 35 | ProtectSystem=strict 36 | RemoveIPC=true 37 | RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX 38 | RestrictNamespaces=true 39 | RestrictRealtime=true 40 | RestrictSUIDSGID=true 41 | RuntimeDirectory=headscale 42 | RuntimeDirectoryMode=0750 43 | StateDirectory=headscale 44 | StateDirectoryMode=0750 45 | SystemCallArchitectures=native 46 | SystemCallFilter=@chown 47 | SystemCallFilter=@system-service 48 | SystemCallFilter=~@privileged 49 | UMask=0077 50 | 51 | [Install] 52 | WantedBy=multi-user.target 53 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | run: 3 | timeout: 10m 4 | build-tags: 5 | - ts2019 6 | 7 | issues: 8 | skip-dirs: 9 | - gen 10 | linters: 11 | enable-all: true 12 | disable: 13 | - depguard 14 | 15 | - revive 16 | - lll 17 | - gofmt 18 | - gochecknoglobals 19 | - gochecknoinits 20 | - gocognit 21 | - funlen 22 | - tagliatelle 23 | - godox 24 | - ireturn 25 | - execinquery 26 | - exhaustruct 27 | - nolintlint 28 | - musttag # causes issues with imported libs 29 | - depguard 30 | - exportloopref 31 | 32 | # We should strive to enable these: 33 | - wrapcheck 34 | - dupl 35 | - makezero 36 | - maintidx 37 | 38 | # Limits the methods of an interface to 10. We have more in integration tests 39 | - interfacebloat 40 | 41 | # We might want to enable this, but it might be a lot of work 42 | - cyclop 43 | - nestif 44 | - wsl # might be incompatible with gofumpt 45 | - testpackage 46 | - paralleltest 47 | 48 | linters-settings: 49 | varnamelen: 50 | ignore-type-assert-ok: true 51 | ignore-map-index-ok: true 52 | ignore-names: 53 | - err 54 | - db 55 | - id 56 | - ip 57 | - ok 58 | - c 59 | - tt 60 | - tx 61 | - rx 62 | 63 | gocritic: 64 | disabled-checks: 65 | - appendAssign 66 | # TODO(kradalby): Remove this 67 | - ifElseChain 68 | 69 | nlreturn: 70 | block-size: 4 71 | -------------------------------------------------------------------------------- /docs/about/clients.md: -------------------------------------------------------------------------------- 1 | # Client and operating system support 2 | 3 | We aim to support the [**last 10 releases** of the Tailscale client](https://tailscale.com/changelog#client) on all 4 | provided operating systems and platforms. Some platforms might require additional configuration to connect with 5 | headscale. 6 | 7 | | OS | Supports headscale | 8 | | ------- | ----------------------------------------------------------------------------------------------------- | 9 | | Linux | Yes | 10 | | OpenBSD | Yes | 11 | | FreeBSD | Yes | 12 | | Windows | Yes (see [docs](../usage/connect/windows.md) and `/windows` on your headscale for more information) | 13 | | Android | Yes (see [docs](../usage/connect/android.md) for more information) | 14 | | macOS | Yes (see [docs](../usage/connect/apple.md#macos) and `/apple` on your headscale for more information) | 15 | | iOS | Yes (see [docs](../usage/connect/apple.md#ios) and `/apple` on your headscale for more information) | 16 | | tvOS | Yes (see [docs](../usage/connect/apple.md#tvos) and `/apple` on your headscale for more information) | 17 | -------------------------------------------------------------------------------- /hscontrol/policy/matcher/matcher.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | 3 | import ( 4 | "net/netip" 5 | 6 | "github.com/juanfont/headscale/hscontrol/util" 7 | "go4.org/netipx" 8 | "tailscale.com/tailcfg" 9 | ) 10 | 11 | type Match struct { 12 | Srcs *netipx.IPSet 13 | Dests *netipx.IPSet 14 | } 15 | 16 | func MatchFromFilterRule(rule tailcfg.FilterRule) Match { 17 | dests := []string{} 18 | for _, dest := range rule.DstPorts { 19 | dests = append(dests, dest.IP) 20 | } 21 | 22 | return MatchFromStrings(rule.SrcIPs, dests) 23 | } 24 | 25 | func MatchFromStrings(sources, destinations []string) Match { 26 | srcs := new(netipx.IPSetBuilder) 27 | dests := new(netipx.IPSetBuilder) 28 | 29 | for _, srcIP := range sources { 30 | set, _ := util.ParseIPSet(srcIP, nil) 31 | 32 | srcs.AddSet(set) 33 | } 34 | 35 | for _, dest := range destinations { 36 | set, _ := util.ParseIPSet(dest, nil) 37 | 38 | dests.AddSet(set) 39 | } 40 | 41 | srcsSet, _ := srcs.IPSet() 42 | destsSet, _ := dests.IPSet() 43 | 44 | match := Match{ 45 | Srcs: srcsSet, 46 | Dests: destsSet, 47 | } 48 | 49 | return match 50 | } 51 | 52 | func (m *Match) SrcsContainsIPs(ips []netip.Addr) bool { 53 | for _, ip := range ips { 54 | if m.Srcs.Contains(ip) { 55 | return true 56 | } 57 | } 58 | 59 | return false 60 | } 61 | 62 | func (m *Match) DestsContainsIP(ips []netip.Addr) bool { 63 | for _, ip := range ips { 64 | if m.Dests.Contains(ip) { 65 | return true 66 | } 67 | } 68 | 69 | return false 70 | } 71 | -------------------------------------------------------------------------------- /hscontrol/templates/general.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "github.com/chasefleming/elem-go" 5 | "github.com/chasefleming/elem-go/attrs" 6 | "github.com/chasefleming/elem-go/styles" 7 | ) 8 | 9 | var bodyStyle = styles.Props{ 10 | styles.Margin: "40px auto", 11 | styles.MaxWidth: "800px", 12 | styles.LineHeight: "1.5", 13 | styles.FontSize: "16px", 14 | styles.Color: "#444", 15 | styles.Padding: "0 10px", 16 | styles.FontFamily: "Sans-serif", 17 | } 18 | 19 | var headerStyle = styles.Props{ 20 | styles.LineHeight: "1.2", 21 | } 22 | 23 | func headerOne(text string) *elem.Element { 24 | return elem.H1(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text)) 25 | } 26 | 27 | func headerTwo(text string) *elem.Element { 28 | return elem.H2(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text)) 29 | } 30 | 31 | func headerThree(text string) *elem.Element { 32 | return elem.H3(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text)) 33 | } 34 | 35 | func HtmlStructure(head, body *elem.Element) *elem.Element { 36 | return elem.Html(nil, 37 | elem.Head( 38 | attrs.Props{ 39 | attrs.Lang: "en", 40 | }, 41 | elem.Meta(attrs.Props{ 42 | attrs.Charset: "UTF-8", 43 | }), 44 | elem.Meta(attrs.Props{ 45 | attrs.HTTPequiv: "X-UA-Compatible", 46 | attrs.Content: "IE=edge", 47 | }), 48 | elem.Meta(attrs.Props{ 49 | attrs.Name: "viewport", 50 | attrs.Content: "width=device-width, initial-scale=1.0", 51 | }), 52 | head, 53 | ), 54 | body, 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /hscontrol/util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | const ( 16 | Base8 = 8 17 | Base10 = 10 18 | BitSize16 = 16 19 | BitSize32 = 32 20 | BitSize64 = 64 21 | PermissionFallback = 0o700 22 | ) 23 | 24 | func AbsolutePathFromConfigPath(path string) string { 25 | // If a relative path is provided, prefix it with the directory where 26 | // the config file was found. 27 | if (path != "") && !strings.HasPrefix(path, string(os.PathSeparator)) { 28 | dir, _ := filepath.Split(viper.ConfigFileUsed()) 29 | if dir != "" { 30 | path = filepath.Join(dir, path) 31 | } 32 | } 33 | 34 | return path 35 | } 36 | 37 | func GetFileMode(key string) fs.FileMode { 38 | modeStr := viper.GetString(key) 39 | 40 | mode, err := strconv.ParseUint(modeStr, Base8, BitSize64) 41 | if err != nil { 42 | return PermissionFallback 43 | } 44 | 45 | return fs.FileMode(mode) 46 | } 47 | 48 | func EnsureDir(dir string) error { 49 | if _, err := os.Stat(dir); os.IsNotExist(err) { 50 | err := os.MkdirAll(dir, PermissionFallback) 51 | if err != nil { 52 | if errors.Is(err, os.ErrPermission) { 53 | return fmt.Errorf( 54 | "creating directory %s, failed with permission error, is it located somewhere Headscale can write?", 55 | dir, 56 | ) 57 | } 58 | 59 | return fmt.Errorf("creating directory %s: %w", dir, err) 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /docs/setup/requirements.md: -------------------------------------------------------------------------------- 1 | # Requirements 2 | 3 | Headscale should just work as long as the following requirements are met: 4 | 5 | - A server with a public IP address for headscale. A dual-stack setup with a public IPv4 and a public IPv6 address is 6 | recommended. 7 | - Headscale is served via HTTPS on port 443[^1]. 8 | - A reasonably modern Linux or BSD based operating system. 9 | - A dedicated local user account to run headscale. 10 | - A little bit of command line knowledge to configure and operate headscale. 11 | 12 | ## Assumptions 13 | 14 | The headscale documentation and the provided examples are written with a few assumptions in mind: 15 | 16 | - Headscale is running as system service via a dedicated local user `headscale`. 17 | - The [configuration](../ref/configuration.md) is loaded from `/etc/headscale/config.yaml`. 18 | - SQLite is used as database. 19 | - The data directory for headscale (used for private keys, ACLs, SQLite database, …) is located in `/var/lib/headscale`. 20 | - URLs and values that need to be replaced by the user are either denoted as `<VALUE_TO_CHANGE>` or use placeholder 21 | values such as `headscale.example.com`. 22 | 23 | Please adjust to your local environment accordingly. 24 | 25 | [^1]: 26 | The Tailscale client assumes HTTPS on port 443 in certain situations. Serving headscale either via HTTP or via HTTPS 27 | on a port other than 443 is possible but sticking with HTTPS on port 443 is strongly recommended for production 28 | setups. See [issue 2164](https://github.com/juanfont/headscale/issues/2164) for more information. 29 | -------------------------------------------------------------------------------- /integration/tailscale.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "io" 5 | "net/netip" 6 | "net/url" 7 | 8 | "github.com/juanfont/headscale/integration/dockertestutil" 9 | "github.com/juanfont/headscale/integration/tsic" 10 | "tailscale.com/ipn/ipnstate" 11 | "tailscale.com/net/netcheck" 12 | "tailscale.com/types/netmap" 13 | ) 14 | 15 | // nolint 16 | type TailscaleClient interface { 17 | Hostname() string 18 | Shutdown() (string, string, error) 19 | Version() string 20 | Execute( 21 | command []string, 22 | options ...dockertestutil.ExecuteCommandOption, 23 | ) (string, string, error) 24 | Login(loginServer, authKey string) error 25 | LoginWithURL(loginServer string) (*url.URL, error) 26 | Logout() error 27 | Up() error 28 | Down() error 29 | IPs() ([]netip.Addr, error) 30 | FQDN() (string, error) 31 | Status(...bool) (*ipnstate.Status, error) 32 | Netmap() (*netmap.NetworkMap, error) 33 | DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error) 34 | Netcheck() (*netcheck.Report, error) 35 | WaitForNeedsLogin() error 36 | WaitForRunning() error 37 | WaitForPeers(expected int) error 38 | Ping(hostnameOrIP string, opts ...tsic.PingOption) error 39 | Curl(url string, opts ...tsic.CurlOption) (string, error) 40 | ID() string 41 | ReadFile(path string) ([]byte, error) 42 | 43 | // FailingPeersAsString returns a formatted-ish multi-line-string of peers in the client 44 | // and a bool indicating if the clients online count and peer count is equal. 45 | FailingPeersAsString() (string, bool, error) 46 | 47 | WriteLogs(stdout, stderr io.Writer) error 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Juan Font 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.github/workflows/docs-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs 2 | 3 | on: 4 | push: 5 | branches: 6 | # Main branch for development docs 7 | - main 8 | 9 | # Doc maintenance branches 10 | - doc/[0-9]+.[0-9]+.[0-9]+ 11 | tags: 12 | # Stable release tags 13 | - v[0-9]+.[0-9]+.[0-9]+ 14 | paths: 15 | - "docs/**" 16 | - "mkdocs.yml" 17 | workflow_dispatch: 18 | 19 | jobs: 20 | deploy: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Install python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: 3.x 31 | - name: Setup cache 32 | uses: actions/cache@v4 33 | with: 34 | key: ${{ github.ref }} 35 | path: .cache 36 | - name: Setup dependencies 37 | run: pip install -r docs/requirements.txt 38 | - name: Configure git 39 | run: | 40 | git config user.name github-actions 41 | git config user.email github-actions@github.com 42 | - name: Deploy development docs 43 | if: github.ref == 'refs/heads/main' 44 | run: mike deploy --push development unstable 45 | - name: Deploy stable docs from doc branches 46 | if: startsWith(github.ref, 'refs/heads/doc/') 47 | run: mike deploy --push ${GITHUB_REF_NAME##*/} 48 | - name: Deploy stable docs from tag 49 | if: startsWith(github.ref, 'refs/tags/v') 50 | # This assumes that only newer tags are pushed 51 | run: mike deploy --push --update-aliases ${GITHUB_REF_NAME#v} stable latest 52 | -------------------------------------------------------------------------------- /hscontrol/capver/capver_test.go: -------------------------------------------------------------------------------- 1 | package capver 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "tailscale.com/tailcfg" 8 | ) 9 | 10 | func TestTailscaleLatestMajorMinor(t *testing.T) { 11 | tests := []struct { 12 | n int 13 | stripV bool 14 | expected []string 15 | }{ 16 | {3, false, []string{"v1.76", "v1.78", "v1.80"}}, 17 | {2, true, []string{"1.78", "1.80"}}, 18 | // Lazy way to see all supported versions 19 | {10, true, []string{ 20 | "1.62", 21 | "1.64", 22 | "1.66", 23 | "1.68", 24 | "1.70", 25 | "1.72", 26 | "1.74", 27 | "1.76", 28 | "1.78", 29 | "1.80", 30 | }}, 31 | {0, false, nil}, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run("", func(t *testing.T) { 36 | output := TailscaleLatestMajorMinor(test.n, test.stripV) 37 | if diff := cmp.Diff(output, test.expected); diff != "" { 38 | t.Errorf("TailscaleLatestMajorMinor(%d, %v) mismatch (-want +got):\n%s", test.n, test.stripV, diff) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestCapVerMinimumTailscaleVersion(t *testing.T) { 45 | tests := []struct { 46 | input tailcfg.CapabilityVersion 47 | expected string 48 | }{ 49 | {85, "v1.58.0"}, 50 | {90, "v1.64.0"}, 51 | {95, "v1.66.0"}, 52 | {106, "v1.74.0"}, 53 | {109, "v1.78.0"}, 54 | {9001, ""}, // Test case for a version higher than any in the map 55 | {60, ""}, // Test case for a version lower than any in the map 56 | } 57 | 58 | for _, test := range tests { 59 | t.Run("", func(t *testing.T) { 60 | output := TailscaleVersion(test.input) 61 | if output != test.expected { 62 | t.Errorf("CapVerFromTailscaleVersion(%d) = %s; want %s", test.input, output, test.expected) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1738297584, 24 | "narHash": "sha256-AYvaFBzt8dU0fcSK2jKD0Vg23K2eIRxfsVXIPCW9a0E=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "9189ac18287c599860e878e905da550aa6dec1cd", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /Dockerfile.tailscale-HEAD: -------------------------------------------------------------------------------- 1 | # Copyright (c) Tailscale Inc & AUTHORS 2 | # SPDX-License-Identifier: BSD-3-Clause 3 | 4 | # This Dockerfile is more or less lifted from tailscale/tailscale 5 | # to ensure a similar build process when testing the HEAD of tailscale. 6 | 7 | FROM golang:1.23-alpine AS build-env 8 | 9 | WORKDIR /go/src 10 | 11 | RUN apk add --no-cache git 12 | 13 | # Replace `RUN git...` with `COPY` and a local checked out version of Tailscale in `./tailscale` 14 | # to test specific commits of the Tailscale client. This is useful when trying to find out why 15 | # something specific broke between two versions of Tailscale with for example `git bisect`. 16 | # COPY ./tailscale . 17 | RUN git clone https://github.com/tailscale/tailscale.git 18 | 19 | WORKDIR /go/src/tailscale 20 | 21 | 22 | # see build_docker.sh 23 | ARG VERSION_LONG="" 24 | ENV VERSION_LONG=$VERSION_LONG 25 | ARG VERSION_SHORT="" 26 | ENV VERSION_SHORT=$VERSION_SHORT 27 | ARG VERSION_GIT_HASH="" 28 | ENV VERSION_GIT_HASH=$VERSION_GIT_HASH 29 | ARG TARGETARCH 30 | 31 | ARG BUILD_TAGS="" 32 | 33 | RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\ 34 | -X tailscale.com/version.longStamp=$VERSION_LONG \ 35 | -X tailscale.com/version.shortStamp=$VERSION_SHORT \ 36 | -X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \ 37 | -v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot 38 | 39 | FROM alpine:3.18 40 | RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl 41 | 42 | COPY --from=build-env /go/bin/* /usr/local/bin/ 43 | # For compat with the previous run.sh, although ideally you should be 44 | # using build_docker.sh which sets an entrypoint for the image. 45 | RUN mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh 46 | -------------------------------------------------------------------------------- /integration/dockertestutil/logs.go: -------------------------------------------------------------------------------- 1 | package dockertestutil 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "log" 8 | "os" 9 | "path" 10 | 11 | "github.com/ory/dockertest/v3" 12 | "github.com/ory/dockertest/v3/docker" 13 | ) 14 | 15 | const filePerm = 0o644 16 | 17 | func WriteLog( 18 | pool *dockertest.Pool, 19 | resource *dockertest.Resource, 20 | stdout io.Writer, 21 | stderr io.Writer, 22 | ) error { 23 | return pool.Client.Logs( 24 | docker.LogsOptions{ 25 | Context: context.TODO(), 26 | Container: resource.Container.ID, 27 | OutputStream: stdout, 28 | ErrorStream: stderr, 29 | Tail: "all", 30 | RawTerminal: false, 31 | Stdout: true, 32 | Stderr: true, 33 | Follow: false, 34 | Timestamps: false, 35 | }, 36 | ) 37 | } 38 | 39 | func SaveLog( 40 | pool *dockertest.Pool, 41 | resource *dockertest.Resource, 42 | basePath string, 43 | ) (string, string, error) { 44 | err := os.MkdirAll(basePath, os.ModePerm) 45 | if err != nil { 46 | return "", "", err 47 | } 48 | 49 | var stdout, stderr bytes.Buffer 50 | err = WriteLog(pool, resource, &stdout, &stderr) 51 | if err != nil { 52 | return "", "", err 53 | } 54 | 55 | log.Printf("Saving logs for %s to %s\n", resource.Container.Name, basePath) 56 | 57 | stdoutPath := path.Join(basePath, resource.Container.Name+".stdout.log") 58 | err = os.WriteFile( 59 | stdoutPath, 60 | stdout.Bytes(), 61 | filePerm, 62 | ) 63 | if err != nil { 64 | return "", "", err 65 | } 66 | 67 | stderrPath := path.Join(basePath, resource.Container.Name+".stderr.log") 68 | err = os.WriteFile( 69 | stderrPath, 70 | stderr.Bytes(), 71 | filePerm, 72 | ) 73 | if err != nil { 74 | return "", "", err 75 | } 76 | 77 | return stdoutPath, stderrPath, nil 78 | } 79 | -------------------------------------------------------------------------------- /docs/ref/exit-node.md: -------------------------------------------------------------------------------- 1 | # Exit Nodes 2 | 3 | ## On the node 4 | 5 | Register the node and make it advertise itself as an exit node: 6 | 7 | ```console 8 | $ sudo tailscale up --login-server https://headscale.example.com --advertise-exit-node 9 | ``` 10 | 11 | If the node is already registered, it can advertise exit capabilities like this: 12 | 13 | ```console 14 | $ sudo tailscale set --advertise-exit-node 15 | ``` 16 | 17 | 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 forwarding. 18 | 19 | ## On the control server 20 | 21 | ```console 22 | $ # list nodes 23 | $ headscale routes list 24 | ID | Node | Prefix | Advertised | Enabled | Primary 25 | 1 | | 0.0.0.0/0 | false | false | - 26 | 2 | | ::/0 | false | false | - 27 | 3 | phobos | 0.0.0.0/0 | true | false | - 28 | 4 | phobos | ::/0 | true | false | - 29 | 30 | $ # enable routes for phobos 31 | $ headscale routes enable -r 3 32 | $ headscale routes enable -r 4 33 | 34 | $ # Check node list again. The routes are now enabled. 35 | $ headscale routes list 36 | ID | Node | Prefix | Advertised | Enabled | Primary 37 | 1 | | 0.0.0.0/0 | false | false | - 38 | 2 | | ::/0 | false | false | - 39 | 3 | phobos | 0.0.0.0/0 | true | true | - 40 | 4 | phobos | ::/0 | true | true | - 41 | ``` 42 | 43 | ## On the client 44 | 45 | The exit node can now be used with: 46 | 47 | ```console 48 | $ sudo tailscale set --exit-node phobos 49 | ``` 50 | 51 | Check the official [Tailscale documentation](https://tailscale.com/kb/1103/exit-nodes#use-the-exit-node) for how to do it on your device. 52 | -------------------------------------------------------------------------------- /integration/hsic/config.go: -------------------------------------------------------------------------------- 1 | package hsic 2 | 3 | import "github.com/juanfont/headscale/hscontrol/types" 4 | 5 | func MinimumConfigYAML() string { 6 | return ` 7 | private_key_path: /tmp/private.key 8 | noise: 9 | private_key_path: /tmp/noise_private.key 10 | ` 11 | } 12 | 13 | func DefaultConfigEnv() map[string]string { 14 | return map[string]string{ 15 | "HEADSCALE_LOG_LEVEL": "trace", 16 | "HEADSCALE_POLICY_PATH": "", 17 | "HEADSCALE_DATABASE_TYPE": "sqlite", 18 | "HEADSCALE_DATABASE_SQLITE_PATH": "/tmp/integration_test_db.sqlite3", 19 | "HEADSCALE_DATABASE_DEBUG": "0", 20 | "HEADSCALE_DATABASE_GORM_SLOW_THRESHOLD": "1", 21 | "HEADSCALE_EPHEMERAL_NODE_INACTIVITY_TIMEOUT": "30m", 22 | "HEADSCALE_PREFIXES_V4": "100.64.0.0/10", 23 | "HEADSCALE_PREFIXES_V6": "fd7a:115c:a1e0::/48", 24 | "HEADSCALE_DNS_BASE_DOMAIN": "headscale.net", 25 | "HEADSCALE_DNS_MAGIC_DNS": "true", 26 | "HEADSCALE_DNS_NAMESERVERS_GLOBAL": "127.0.0.11 1.1.1.1", 27 | "HEADSCALE_PRIVATE_KEY_PATH": "/tmp/private.key", 28 | "HEADSCALE_NOISE_PRIVATE_KEY_PATH": "/tmp/noise_private.key", 29 | "HEADSCALE_METRICS_LISTEN_ADDR": "0.0.0.0:9090", 30 | "HEADSCALE_DERP_URLS": "https://controlplane.tailscale.com/derpmap/default", 31 | "HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false", 32 | "HEADSCALE_DERP_UPDATE_FREQUENCY": "1m", 33 | 34 | // a bunch of tests (ACL/Policy) rely on predictable IP alloc, 35 | // so ensure the sequential alloc is used by default. 36 | "HEADSCALE_PREFIXES_ALLOCATION": string(types.IPAllocationStrategySequential), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/ref/integration/web-ui.md: -------------------------------------------------------------------------------- 1 | # Web interfaces for headscale 2 | 3 | !!! warning "Community contributions" 4 | 5 | This page contains community contributions. The projects listed here are not 6 | maintained by the headscale authors and are written by community members. 7 | 8 | Headscale doesn't provide a built-in web interface but users may pick one from the available options. 9 | 10 | | Name | Repository Link | Description | 11 | | --------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | 12 | | headscale-webui | [Github](https://github.com/ifargle/headscale-webui) | A simple headscale web UI for small-scale deployments. | 13 | | headscale-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server | 14 | | HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend environment required | 15 | | Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale | 16 | | headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale | 17 | | ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins | 18 | 19 | You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel. 20 | -------------------------------------------------------------------------------- /.github/workflows/gh-action-integration-generator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //go:generate go run ./gh-action-integration-generator.go 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "log" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | func findTests() []string { 14 | rgBin, err := exec.LookPath("rg") 15 | if err != nil { 16 | log.Fatalf("failed to find rg (ripgrep) binary") 17 | } 18 | 19 | args := []string{ 20 | "--regexp", "func (Test.+)\\(.*", 21 | "../../integration/", 22 | "--replace", "$1", 23 | "--sort", "path", 24 | "--no-line-number", 25 | "--no-filename", 26 | "--no-heading", 27 | } 28 | 29 | cmd := exec.Command(rgBin, args...) 30 | var out bytes.Buffer 31 | cmd.Stdout = &out 32 | err = cmd.Run() 33 | if err != nil { 34 | log.Fatalf("failed to run command: %s", err) 35 | } 36 | 37 | tests := strings.Split(strings.TrimSpace(out.String()), "\n") 38 | return tests 39 | } 40 | 41 | func updateYAML(tests []string) { 42 | testsForYq := fmt.Sprintf("[%s]", strings.Join(tests, ", ")) 43 | 44 | yqCommand := fmt.Sprintf( 45 | "yq eval '.jobs.integration-test.strategy.matrix.test = %s' ./test-integration.yaml -i", 46 | testsForYq, 47 | ) 48 | cmd := exec.Command("bash", "-c", yqCommand) 49 | 50 | var stdout bytes.Buffer 51 | var stderr bytes.Buffer 52 | cmd.Stdout = &stdout 53 | cmd.Stderr = &stderr 54 | err := cmd.Run() 55 | if err != nil { 56 | log.Printf("stdout: %s", stdout.String()) 57 | log.Printf("stderr: %s", stderr.String()) 58 | log.Fatalf("failed to run yq command: %s", err) 59 | } 60 | 61 | fmt.Println("YAML file updated successfully") 62 | } 63 | 64 | func main() { 65 | tests := findTests() 66 | 67 | quotedTests := make([]string, len(tests)) 68 | for i, test := range tests { 69 | quotedTests[i] = fmt.Sprintf("\"%s\"", test) 70 | } 71 | 72 | updateYAML(quotedTests) 73 | } 74 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Calculate version 2 | version ?= $(shell git describe --always --tags --dirty) 3 | 4 | rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d)) 5 | 6 | # Determine if OS supports pie 7 | GOOS ?= $(shell uname | tr '[:upper:]' '[:lower:]') 8 | ifeq ($(filter $(GOOS), openbsd netbsd soloaris plan9), ) 9 | pieflags = -buildmode=pie 10 | else 11 | endif 12 | 13 | # GO_SOURCES = $(wildcard *.go) 14 | # PROTO_SOURCES = $(wildcard **/*.proto) 15 | GO_SOURCES = $(call rwildcard,,*.go) 16 | PROTO_SOURCES = $(call rwildcard,,*.proto) 17 | 18 | 19 | build: 20 | nix build 21 | 22 | dev: lint test build 23 | 24 | test: 25 | gotestsum -- -short -race -coverprofile=coverage.out ./... 26 | 27 | test_integration: 28 | docker run \ 29 | -t --rm \ 30 | -v ~/.cache/hs-integration-go:/go \ 31 | --name headscale-test-suite \ 32 | -v $$PWD:$$PWD -w $$PWD/integration \ 33 | -v /var/run/docker.sock:/var/run/docker.sock \ 34 | -v $$PWD/control_logs:/tmp/control \ 35 | golang:1 \ 36 | go run gotest.tools/gotestsum@latest -- -race -failfast ./... -timeout 120m -parallel 8 37 | 38 | lint: 39 | golangci-lint run --fix --timeout 10m 40 | 41 | fmt: fmt-go fmt-prettier fmt-proto 42 | 43 | fmt-prettier: 44 | prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}' 45 | prettier --write --print-width 80 --prose-wrap always CHANGELOG.md 46 | 47 | fmt-go: 48 | # TODO(kradalby): Reeval if we want to use 88 in the future. 49 | # golines --max-len=88 --base-formatter=gofumpt -w $(GO_SOURCES) 50 | gofumpt -l -w . 51 | golangci-lint run --fix 52 | 53 | fmt-proto: 54 | clang-format -i $(PROTO_SOURCES) 55 | 56 | proto-lint: 57 | cd proto/ && go run github.com/bufbuild/buf/cmd/buf lint 58 | 59 | compress: build 60 | upx --brute headscale 61 | 62 | generate: 63 | rm -rf gen 64 | buf generate proto 65 | -------------------------------------------------------------------------------- /docs/ref/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | - Headscale loads its configuration from a YAML file 4 | - It searches for `config.yaml` in the following paths: 5 | - `/etc/headscale` 6 | - `$HOME/.headscale` 7 | - the current working directory 8 | - Use the command line flag `-c`, `--config` to load the configuration from a different path 9 | - Validate the configuration file with: `headscale configtest` 10 | 11 | !!! example "Get the [example configuration from the GitHub repository](https://github.com/juanfont/headscale/blob/main/config-example.yaml)" 12 | 13 | Always select the [same GitHub tag](https://github.com/juanfont/headscale/tags) as the released version you use to 14 | ensure you have the correct example configuration. The `main` branch might contain unreleased changes. 15 | 16 | === "View on GitHub" 17 | 18 | * Development version: <https://github.com/juanfont/headscale/blob/main/config-example.yaml> 19 | * Version {{ headscale.version }}: <https://github.com/juanfont/headscale/blob/v{{ headscale.version }}/config-example.yaml> 20 | 21 | === "Download with `wget`" 22 | 23 | ```shell 24 | # Development version 25 | wget -O config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml 26 | 27 | # Version {{ headscale.version }} 28 | wget -O config.yaml https://raw.githubusercontent.com/juanfont/headscale/v{{ headscale.version }}/config-example.yaml 29 | ``` 30 | 31 | === "Download with `curl`" 32 | 33 | ```shell 34 | # Development version 35 | curl -o config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml 36 | 37 | # Version {{ headscale.version }} 38 | curl -o config.yaml https://raw.githubusercontent.com/juanfont/headscale/v{{ headscale.version }}/config-example.yaml 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/about/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | Headscale aims to implement a self-hosted, open source alternative to the Tailscale control server. Headscale's goal is 4 | to provide self-hosters and hobbyists with an open-source server they can use for their projects and labs. This page 5 | provides on overview of headscale's feature and compatibility with the Tailscale control server: 6 | 7 | - [x] Full "base" support of Tailscale's features 8 | - [x] Node registration 9 | - [x] Interactive 10 | - [x] Pre authenticated key 11 | - [x] [DNS](https://tailscale.com/kb/1054/dns) 12 | - [x] [MagicDNS](https://tailscale.com/kb/1081/magicdns) 13 | - [x] [Global and restricted nameservers (split DNS)](https://tailscale.com/kb/1054/dns#nameservers) 14 | - [x] [search domains](https://tailscale.com/kb/1054/dns#search-domains) 15 | - [x] [Extra DNS records (headscale only)](../ref/dns.md#setting-extra-dns-records) 16 | - [x] [Taildrop (File Sharing)](https://tailscale.com/kb/1106/taildrop) 17 | - [x] Routing advertising (including exit nodes) 18 | - [x] Dual stack (IPv4 and IPv6) 19 | - [x] Ephemeral nodes 20 | - [x] Embedded [DERP server](https://tailscale.com/kb/1232/derp-servers) 21 | - [x] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D)) 22 | - [x] ACL management via API 23 | - [x] `autogroup:internet` 24 | - [ ] `autogroup:self` 25 | - [ ] `autogroup:member` 26 | * [ ] Node registration using Single-Sign-On (OpenID Connect) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC)) 27 | - [x] Basic registration 28 | - [x] Update user profile from identity provider 29 | - [ ] Dynamic ACL support 30 | - [ ] OIDC groups cannot be used in ACLs 31 | - [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040)) 32 | - [ ] [Serve](https://tailscale.com/kb/1312/serve) ([#1234](https://github.com/juanfont/headscale/issues/1921)) 33 | -------------------------------------------------------------------------------- /hscontrol/types/routes_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "net/netip" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/juanfont/headscale/hscontrol/util" 10 | ) 11 | 12 | func TestPrefixMap(t *testing.T) { 13 | ipp := func(s string) netip.Prefix { return netip.MustParsePrefix(s) } 14 | 15 | tests := []struct { 16 | rs Routes 17 | want map[netip.Prefix][]Route 18 | }{ 19 | { 20 | rs: Routes{ 21 | Route{ 22 | Prefix: ipp("10.0.0.0/24"), 23 | }, 24 | }, 25 | want: map[netip.Prefix][]Route{ 26 | ipp("10.0.0.0/24"): Routes{ 27 | Route{ 28 | Prefix: ipp("10.0.0.0/24"), 29 | }, 30 | }, 31 | }, 32 | }, 33 | { 34 | rs: Routes{ 35 | Route{ 36 | Prefix: ipp("10.0.0.0/24"), 37 | }, 38 | Route{ 39 | Prefix: ipp("10.0.1.0/24"), 40 | }, 41 | }, 42 | want: map[netip.Prefix][]Route{ 43 | ipp("10.0.0.0/24"): Routes{ 44 | Route{ 45 | Prefix: ipp("10.0.0.0/24"), 46 | }, 47 | }, 48 | ipp("10.0.1.0/24"): Routes{ 49 | Route{ 50 | Prefix: ipp("10.0.1.0/24"), 51 | }, 52 | }, 53 | }, 54 | }, 55 | { 56 | rs: Routes{ 57 | Route{ 58 | Prefix: ipp("10.0.0.0/24"), 59 | Enabled: true, 60 | }, 61 | Route{ 62 | Prefix: ipp("10.0.0.0/24"), 63 | Enabled: false, 64 | }, 65 | }, 66 | want: map[netip.Prefix][]Route{ 67 | ipp("10.0.0.0/24"): Routes{ 68 | Route{ 69 | Prefix: ipp("10.0.0.0/24"), 70 | Enabled: true, 71 | }, 72 | Route{ 73 | Prefix: ipp("10.0.0.0/24"), 74 | Enabled: false, 75 | }, 76 | }, 77 | }, 78 | }, 79 | } 80 | 81 | for idx, tt := range tests { 82 | t.Run(fmt.Sprintf("test-%d", idx), func(t *testing.T) { 83 | got := tt.rs.PrefixMap() 84 | if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" { 85 | t.Errorf("PrefixMap() unexpected result (-want +got):\n%s", diff) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /docs/setup/install/community.md: -------------------------------------------------------------------------------- 1 | # Community packages 2 | 3 | Several Linux distributions and community members provide packages for headscale. Those packages may be used instead of 4 | the [official releases](./official.md) provided by the headscale maintainers. Such packages offer improved integration 5 | for their targeted operating system and usually: 6 | 7 | - setup a dedicated local user account to run headscale 8 | - provide a default configuration 9 | - install headscale as system service 10 | 11 | !!! warning "Community packages might be outdated" 12 | 13 | The packages mentioned on this page might be outdated or unmaintained. Use the [official releases](./official.md) to 14 | get the current stable version or to test pre-releases. 15 | 16 | [![Packaging status](https://repology.org/badge/vertical-allrepos/headscale.svg)](https://repology.org/project/headscale/versions) 17 | 18 | ## Arch Linux 19 | 20 | Arch Linux offers a package for headscale, install via: 21 | 22 | ```shell 23 | pacman -S headscale 24 | ``` 25 | 26 | The [AUR package `headscale-git`](https://aur.archlinux.org/packages/headscale-git) can be used to build the current 27 | development version. 28 | 29 | ## Fedora, RHEL, CentOS 30 | 31 | A third-party repository for various RPM based distributions is available at: 32 | <https://copr.fedorainfracloud.org/coprs/jonathanspw/headscale/>. The site provides detailed setup and installation 33 | instructions. 34 | 35 | ## Nix, NixOS 36 | 37 | A Nix package is available as: `headscale`. See the [NixOS package site for installation 38 | details](https://search.nixos.org/packages?show=headscale). 39 | 40 | ## Gentoo 41 | 42 | ```shell 43 | emerge --ask net-vpn/headscale 44 | ``` 45 | 46 | Gentoo specific documentation is available [here](https://wiki.gentoo.org/wiki/User:Maffblaster/Drafts/Headscale). 47 | 48 | ## OpenBSD 49 | 50 | Headscale is available in ports. The port installs headscale as system service with `rc.d` and provides usage 51 | instructions upon installation. 52 | 53 | ```shell 54 | pkg_add headscale 55 | ``` 56 | -------------------------------------------------------------------------------- /integration/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ksh 2 | 3 | run_tests() { 4 | test_name=$1 5 | num_tests=$2 6 | 7 | success_count=0 8 | failure_count=0 9 | runtimes=() 10 | 11 | echo "-------------------" 12 | echo "Running Tests for $test_name" 13 | 14 | for ((i = 1; i <= num_tests; i++)); do 15 | docker network prune -f >/dev/null 2>&1 16 | docker rm headscale-test-suite >/dev/null 2>&1 || true 17 | docker kill "$(docker ps -q)" >/dev/null 2>&1 || true 18 | 19 | echo "Run $i" 20 | 21 | start=$(date +%s) 22 | docker run \ 23 | --tty --rm \ 24 | --volume ~/.cache/hs-integration-go:/go \ 25 | --name headscale-test-suite \ 26 | --volume "$PWD:$PWD" -w "$PWD"/integration \ 27 | --volume /var/run/docker.sock:/var/run/docker.sock \ 28 | --volume "$PWD"/control_logs:/tmp/control \ 29 | -e "HEADSCALE_INTEGRATION_POSTGRES" \ 30 | golang:1 \ 31 | go test ./... \ 32 | -failfast \ 33 | -timeout 120m \ 34 | -parallel 1 \ 35 | -run "^$test_name\$" >./control_logs/"$test_name"_"$i".log 2>&1 36 | status=$? 37 | end=$(date +%s) 38 | 39 | runtime=$((end - start)) 40 | runtimes+=("$runtime") 41 | 42 | if [ "$status" -eq 0 ]; then 43 | ((success_count++)) 44 | else 45 | ((failure_count++)) 46 | fi 47 | done 48 | 49 | echo "-------------------" 50 | echo "Test Summary for $test_name" 51 | echo "-------------------" 52 | echo "Total Tests: $num_tests" 53 | echo "Successful Tests: $success_count" 54 | echo "Failed Tests: $failure_count" 55 | echo "Runtimes in seconds: ${runtimes[*]}" 56 | echo 57 | } 58 | 59 | # Check if both arguments are provided 60 | if [ $# -ne 2 ]; then 61 | echo "Usage: $0 <test_name> <num_tests>" 62 | exit 1 63 | fi 64 | 65 | test_name=$1 66 | num_tests=$2 67 | 68 | docker network prune -f 69 | 70 | if [ "$test_name" = "all" ]; then 71 | rg --regexp "func (Test.+)\(.*" ./integration/ --replace '$1' --no-line-number --no-filename --no-heading | sort | while read -r test_name; do 72 | run_tests "$test_name" "$num_tests" 73 | done 74 | else 75 | run_tests "$test_name" "$num_tests" 76 | fi 77 | -------------------------------------------------------------------------------- /docs/setup/install/source.md: -------------------------------------------------------------------------------- 1 | # Build from source 2 | 3 | !!! warning "Community documentation" 4 | 5 | This page is not actively maintained by the headscale authors and is 6 | written by community members. It is _not_ verified by headscale developers. 7 | 8 | **It might be outdated and it might miss necessary steps**. 9 | 10 | Headscale can be built from source using the latest version of [Go](https://golang.org) and [Buf](https://buf.build) 11 | (Protobuf generator). See the [Contributing section in the GitHub 12 | README](https://github.com/juanfont/headscale#contributing) for more information. 13 | 14 | ## OpenBSD 15 | 16 | ### Install from source 17 | 18 | ```shell 19 | # Install prerequisites 20 | pkg_add go 21 | 22 | git clone https://github.com/juanfont/headscale.git 23 | 24 | cd headscale 25 | 26 | # optionally checkout a release 27 | # option a. you can find official release at https://github.com/juanfont/headscale/releases/latest 28 | # option b. get latest tag, this may be a beta release 29 | latestTag=$(git describe --tags `git rev-list --tags --max-count=1`) 30 | 31 | git checkout $latestTag 32 | 33 | go build -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$latestTag" github.com/juanfont/headscale 34 | 35 | # make it executable 36 | chmod a+x headscale 37 | 38 | # copy it to /usr/local/sbin 39 | cp headscale /usr/local/sbin 40 | ``` 41 | 42 | ### Install from source via cross compile 43 | 44 | ```shell 45 | # Install prerequisites 46 | # 1. go v1.20+: headscale newer than 0.21 needs go 1.20+ to compile 47 | # 2. gmake: Makefile in the headscale repo is written in GNU make syntax 48 | 49 | git clone https://github.com/juanfont/headscale.git 50 | 51 | cd headscale 52 | 53 | # optionally checkout a release 54 | # option a. you can find official release at https://github.com/juanfont/headscale/releases/latest 55 | # option b. get latest tag, this may be a beta release 56 | latestTag=$(git describe --tags `git rev-list --tags --max-count=1`) 57 | 58 | git checkout $latestTag 59 | 60 | make build GOOS=openbsd 61 | 62 | # copy headscale to openbsd machine and put it in /usr/local/sbin 63 | ``` 64 | -------------------------------------------------------------------------------- /proto/headscale/v1/device.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | // This is a potential reimplementation of Tailscale's API 8 | // https://github.com/tailscale/tailscale/blob/main/api.md 9 | 10 | message Latency { 11 | float latency_ms = 1; 12 | bool preferred = 2; 13 | } 14 | 15 | message ClientSupports { 16 | bool hair_pinning = 1; 17 | bool ipv6 = 2; 18 | bool pcp = 3; 19 | bool pmp = 4; 20 | bool udp = 5; 21 | bool upnp = 6; 22 | } 23 | 24 | message ClientConnectivity { 25 | repeated string endpoints = 1; 26 | string derp = 2; 27 | bool mapping_varies_by_dest_ip = 3; 28 | map<string, Latency> latency = 4; 29 | ClientSupports client_supports = 5; 30 | } 31 | 32 | message GetDeviceRequest { string id = 1; } 33 | 34 | message GetDeviceResponse { 35 | repeated string addresses = 1; 36 | string id = 2; 37 | string user = 3; 38 | string name = 4; 39 | string hostname = 5; 40 | string client_version = 6; 41 | bool update_available = 7; 42 | string os = 8; 43 | google.protobuf.Timestamp created = 9; 44 | google.protobuf.Timestamp last_seen = 10; 45 | bool key_expiry_disabled = 11; 46 | google.protobuf.Timestamp expires = 12; 47 | bool authorized = 13; 48 | bool is_external = 14; 49 | string machine_key = 15; 50 | string node_key = 16; 51 | bool blocks_incoming_connections = 17; 52 | repeated string enabled_routes = 18; 53 | repeated string advertised_routes = 19; 54 | ClientConnectivity client_connectivity = 20; 55 | } 56 | 57 | message DeleteDeviceRequest { string id = 1; } 58 | 59 | message DeleteDeviceResponse {} 60 | 61 | message GetDeviceRoutesRequest { string id = 1; } 62 | 63 | message GetDeviceRoutesResponse { 64 | repeated string enabled_routes = 1; 65 | repeated string advertised_routes = 2; 66 | } 67 | 68 | message EnableDeviceRoutesRequest { 69 | string id = 1; 70 | repeated string routes = 2; 71 | } 72 | 73 | message EnableDeviceRoutesResponse { 74 | repeated string enabled_routes = 1; 75 | repeated string advertised_routes = 2; 76 | } 77 | -------------------------------------------------------------------------------- /hscontrol/util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "fmt" 7 | "strings" 8 | 9 | "tailscale.com/tailcfg" 10 | ) 11 | 12 | // GenerateRandomBytes returns securely generated random bytes. 13 | // It will return an error if the system's secure random 14 | // number generator fails to function correctly, in which 15 | // case the caller should not continue. 16 | func GenerateRandomBytes(n int) ([]byte, error) { 17 | bytes := make([]byte, n) 18 | 19 | // Note that err == nil only if we read len(b) bytes. 20 | if _, err := rand.Read(bytes); err != nil { 21 | return nil, err 22 | } 23 | 24 | return bytes, nil 25 | } 26 | 27 | // GenerateRandomStringURLSafe returns a URL-safe, base64 encoded 28 | // securely generated random string. 29 | // It will return an error if the system's secure random 30 | // number generator fails to function correctly, in which 31 | // case the caller should not continue. 32 | func GenerateRandomStringURLSafe(n int) (string, error) { 33 | b, err := GenerateRandomBytes(n) 34 | 35 | uenc := base64.RawURLEncoding.EncodeToString(b) 36 | return uenc[:n], err 37 | } 38 | 39 | // GenerateRandomStringDNSSafe returns a DNS-safe 40 | // securely generated random string. 41 | // It will return an error if the system's secure random 42 | // number generator fails to function correctly, in which 43 | // case the caller should not continue. 44 | func GenerateRandomStringDNSSafe(size int) (string, error) { 45 | var str string 46 | var err error 47 | for len(str) < size { 48 | str, err = GenerateRandomStringURLSafe(size) 49 | if err != nil { 50 | return "", err 51 | } 52 | str = strings.ToLower( 53 | strings.ReplaceAll(strings.ReplaceAll(str, "_", ""), "-", ""), 54 | ) 55 | } 56 | 57 | return str[:size], nil 58 | } 59 | 60 | func TailNodesToString(nodes []*tailcfg.Node) string { 61 | temp := make([]string, len(nodes)) 62 | 63 | for index, node := range nodes { 64 | temp[index] = node.Name 65 | } 66 | 67 | return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp)) 68 | } 69 | 70 | func TailMapResponseToString(resp tailcfg.MapResponse) string { 71 | return fmt.Sprintf( 72 | "{ Node: %s, Peers: %s }", 73 | resp.Node.Name, 74 | TailNodesToString(resp.Peers), 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /docs/usage/connect/windows.md: -------------------------------------------------------------------------------- 1 | # Connecting a Windows client 2 | 3 | This documentation has the goal of showing how a user can use the official Windows [Tailscale](https://tailscale.com) client with headscale. 4 | 5 | !!! info "Instructions on your headscale instance" 6 | 7 | An endpoint with information on how to connect your Windows device 8 | is also available at `/windows` on your running instance. 9 | 10 | ## Installation 11 | 12 | Download the [Official Windows Client](https://tailscale.com/download/windows) and install it. 13 | 14 | ## Configuring the headscale URL 15 | 16 | Open a Command Prompt or Powershell and use Tailscale's login command to connect with your headscale instance (e.g 17 | `https://headscale.example.com`): 18 | 19 | ``` 20 | tailscale login --login-server <YOUR_HEADSCALE_URL> 21 | ``` 22 | 23 | Follow the instructions in the opened browser window to finish the configuration. 24 | 25 | ## Troubleshooting 26 | 27 | ### Unattended mode 28 | 29 | By default, Tailscale's Windows client is only running when the user is logged in. If you want to keep Tailscale running 30 | all the time, please enable "Unattended mode": 31 | 32 | - Click on the Tailscale tray icon and select `Preferences` 33 | - Enable `Run unattended` 34 | - Confirm the "Unattended mode" message 35 | 36 | See also [Keep Tailscale running when I'm not logged in to my computer](https://tailscale.com/kb/1088/run-unattended) 37 | 38 | ### Failing node registration 39 | 40 | If you are seeing repeated messages like: 41 | 42 | ``` 43 | [GIN] 2022/02/10 - 16:39:34 | 200 | 1.105306ms | 127.0.0.1 | POST "/machine/redacted" 44 | ``` 45 | 46 | in your headscale output, turn on `DEBUG` logging and look for: 47 | 48 | ``` 49 | 2022-02-11T00:59:29Z DBG Machine registration has expired. Sending a authurl to register machine=redacted 50 | ``` 51 | 52 | This typically means that the registry keys above was not set appropriately. 53 | 54 | To reset and try again, it is important to do the following: 55 | 56 | 1. Shut down the Tailscale service (or the client running in the tray) 57 | 2. Delete Tailscale Application data folder, located at `C:\Users\<USERNAME>\AppData\Local\Tailscale` and try to connect again. 58 | 3. Ensure the Windows node is deleted from headscale (to ensure fresh setup) 59 | 4. Start Tailscale on the Windows machine and retry the login. 60 | -------------------------------------------------------------------------------- /integration/dockertestutil/network.go: -------------------------------------------------------------------------------- 1 | package dockertestutil 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "fmt" 7 | 8 | "github.com/ory/dockertest/v3" 9 | "github.com/ory/dockertest/v3/docker" 10 | ) 11 | 12 | var ErrContainerNotFound = errors.New("container not found") 13 | 14 | func GetFirstOrCreateNetwork(pool *dockertest.Pool, name string) (*dockertest.Network, error) { 15 | networks, err := pool.NetworksByName(name) 16 | if err != nil { 17 | return nil, fmt.Errorf("looking up network names: %w", err) 18 | } 19 | if len(networks) == 0 { 20 | if _, err := pool.CreateNetwork(name); err == nil { 21 | // Create does not give us an updated version of the resource, so we need to 22 | // get it again. 23 | networks, err := pool.NetworksByName(name) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return &networks[0], nil 29 | } else { 30 | return nil, fmt.Errorf("creating network: %w", err) 31 | } 32 | } 33 | 34 | return &networks[0], nil 35 | } 36 | 37 | func AddContainerToNetwork( 38 | pool *dockertest.Pool, 39 | network *dockertest.Network, 40 | testContainer string, 41 | ) error { 42 | containers, err := pool.Client.ListContainers(docker.ListContainersOptions{ 43 | All: true, 44 | Filters: map[string][]string{ 45 | "name": {testContainer}, 46 | }, 47 | }) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | err = pool.Client.ConnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{ 53 | Container: containers[0].ID, 54 | }) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | // TODO(kradalby): This doesn't work reliably, but calling the exact same functions 60 | // seem to work fine... 61 | // if container, ok := pool.ContainerByName("/" + testContainer); ok { 62 | // err := container.ConnectToNetwork(network) 63 | // if err != nil { 64 | // return err 65 | // } 66 | // } 67 | 68 | return nil 69 | } 70 | 71 | // RandomFreeHostPort asks the kernel for a free open port that is ready to use. 72 | // (from https://github.com/phayes/freeport) 73 | func RandomFreeHostPort() (int, error) { 74 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 75 | if err != nil { 76 | return 0, err 77 | } 78 | 79 | listener, err := net.ListenTCP("tcp", addr) 80 | if err != nil { 81 | return 0, err 82 | } 83 | defer listener.Close() 84 | //nolint:forcetypeassert 85 | return listener.Addr().(*net.TCPAddr).Port, nil 86 | } 87 | -------------------------------------------------------------------------------- /docs/usage/connect/apple.md: -------------------------------------------------------------------------------- 1 | # Connecting an Apple client 2 | 3 | This documentation has the goal of showing how a user can use the official iOS and macOS [Tailscale](https://tailscale.com) clients with headscale. 4 | 5 | !!! info "Instructions on your headscale instance" 6 | 7 | An endpoint with information on how to connect your Apple device 8 | is also available at `/apple` on your running instance. 9 | 10 | ## iOS 11 | 12 | ### Installation 13 | 14 | Install the official Tailscale iOS client from the [App Store](https://apps.apple.com/app/tailscale/id1470499037). 15 | 16 | ### Configuring the headscale URL 17 | 18 | - Open the Tailscale app 19 | - Click the account icon in the top-right corner and select `Log in…`. 20 | - Tap the top-right options menu button and select `Use custom coordination server`. 21 | - Enter your instance url (e.g `https://headscale.example.com`) 22 | - Enter your credentials and log in. Headscale should now be working on your iOS device. 23 | 24 | ## macOS 25 | 26 | ### Installation 27 | 28 | Choose one of the available [Tailscale clients for macOS](https://tailscale.com/kb/1065/macos-variants) and install it. 29 | 30 | ### Configuring the headscale URL 31 | 32 | #### Command line 33 | 34 | Use Tailscale's login command to connect with your headscale instance (e.g `https://headscale.example.com`): 35 | 36 | ``` 37 | tailscale login --login-server <YOUR_HEADSCALE_URL> 38 | ``` 39 | 40 | #### GUI 41 | 42 | - Option + Click the Tailscale icon in the menu and hover over the Debug menu 43 | - Under `Custom Login Server`, select `Add Account...` 44 | - Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `Add Account` 45 | - Follow the login procedure in the browser 46 | 47 | ## tvOS 48 | 49 | ### Installation 50 | 51 | Install the official Tailscale tvOS client from the [App Store](https://apps.apple.com/app/tailscale/id1470499037). 52 | 53 | !!! danger 54 | 55 | **Don't** open the Tailscale App after installation! 56 | 57 | ### Configuring the headscale URL 58 | 59 | - Open Settings (the Apple tvOS settings) > Apps > Tailscale 60 | - Under `ALTERNATE COORDINATION SERVER URL`, select `URL` 61 | - Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `OK` 62 | - Return to the tvOS Home screen 63 | - Open Tailscale 64 | - Click the button `Install VPN configuration` and confirm the appearing popup by clicking the `Allow` button 65 | - Scan the QR code and follow the login procedure 66 | -------------------------------------------------------------------------------- /hscontrol/types/routes.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "net/netip" 6 | 7 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1" 8 | "google.golang.org/protobuf/types/known/timestamppb" 9 | "gorm.io/gorm" 10 | "tailscale.com/net/tsaddr" 11 | ) 12 | 13 | type Route struct { 14 | gorm.Model 15 | 16 | NodeID uint64 `gorm:"not null"` 17 | Node *Node 18 | 19 | // TODO(kradalby): change this custom type to netip.Prefix 20 | Prefix netip.Prefix `gorm:"serializer:text"` 21 | 22 | Advertised bool 23 | Enabled bool 24 | IsPrimary bool 25 | } 26 | 27 | type Routes []Route 28 | 29 | func (r *Route) String() string { 30 | return fmt.Sprintf("%s:%s", r.Node.Hostname, netip.Prefix(r.Prefix).String()) 31 | } 32 | 33 | func (r *Route) IsExitRoute() bool { 34 | return tsaddr.IsExitRoute(r.Prefix) 35 | } 36 | 37 | func (r *Route) IsAnnouncable() bool { 38 | return r.Advertised && r.Enabled 39 | } 40 | 41 | func (rs Routes) Prefixes() []netip.Prefix { 42 | prefixes := make([]netip.Prefix, len(rs)) 43 | for i, r := range rs { 44 | prefixes[i] = netip.Prefix(r.Prefix) 45 | } 46 | 47 | return prefixes 48 | } 49 | 50 | // Primaries returns Primary routes from a list of routes. 51 | func (rs Routes) Primaries() Routes { 52 | res := make(Routes, 0) 53 | for _, route := range rs { 54 | if route.IsPrimary { 55 | res = append(res, route) 56 | } 57 | } 58 | 59 | return res 60 | } 61 | 62 | func (rs Routes) PrefixMap() map[netip.Prefix][]Route { 63 | res := map[netip.Prefix][]Route{} 64 | 65 | for _, route := range rs { 66 | if _, ok := res[route.Prefix]; ok { 67 | res[route.Prefix] = append(res[route.Prefix], route) 68 | } else { 69 | res[route.Prefix] = []Route{route} 70 | } 71 | } 72 | 73 | return res 74 | } 75 | 76 | func (rs Routes) Proto() []*v1.Route { 77 | protoRoutes := []*v1.Route{} 78 | 79 | for _, route := range rs { 80 | protoRoute := v1.Route{ 81 | Id: uint64(route.ID), 82 | Prefix: route.Prefix.String(), 83 | Advertised: route.Advertised, 84 | Enabled: route.Enabled, 85 | IsPrimary: route.IsPrimary, 86 | CreatedAt: timestamppb.New(route.CreatedAt), 87 | UpdatedAt: timestamppb.New(route.UpdatedAt), 88 | } 89 | 90 | if route.Node != nil { 91 | protoRoute.Node = route.Node.Proto() 92 | } 93 | 94 | if route.DeletedAt.Valid { 95 | protoRoute.DeletedAt = timestamppb.New(route.DeletedAt.Time) 96 | } 97 | 98 | protoRoutes = append(protoRoutes, &protoRoute) 99 | } 100 | 101 | return protoRoutes 102 | } 103 | -------------------------------------------------------------------------------- /swagger.go: -------------------------------------------------------------------------------- 1 | package headscale 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "html/template" 7 | "net/http" 8 | 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | //go:embed gen/openapiv2/headscale/v1/headscale.swagger.json 13 | var apiV1JSON []byte 14 | 15 | func SwaggerUI( 16 | writer http.ResponseWriter, 17 | req *http.Request, 18 | ) { 19 | swaggerTemplate := template.Must(template.New("swagger").Parse(` 20 | <html> 21 | <head> 22 | <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"> 23 | 24 | <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-standalone-preset.js"></script> 25 | <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js" charset="UTF-8"></script> 26 | </head> 27 | <body> 28 | <div id="swagger-ui"></div> 29 | <script> 30 | window.addEventListener('load', (event) => { 31 | const ui = SwaggerUIBundle({ 32 | url: "/swagger/v1/openapiv2.json", 33 | dom_id: '#swagger-ui', 34 | presets: [ 35 | SwaggerUIBundle.presets.apis, 36 | SwaggerUIBundle.SwaggerUIStandalonePreset 37 | ], 38 | plugins: [ 39 | SwaggerUIBundle.plugins.DownloadUrl 40 | ], 41 | deepLinking: true, 42 | // TODO(kradalby): Figure out why this does not work 43 | // layout: "StandaloneLayout", 44 | }) 45 | window.ui = ui 46 | }); 47 | </script> 48 | </body> 49 | </html>`)) 50 | 51 | var payload bytes.Buffer 52 | if err := swaggerTemplate.Execute(&payload, struct{}{}); err != nil { 53 | log.Error(). 54 | Caller(). 55 | Err(err). 56 | Msg("Could not render Swagger") 57 | 58 | writer.Header().Set("Content-Type", "text/plain; charset=utf-8") 59 | writer.WriteHeader(http.StatusInternalServerError) 60 | _, err := writer.Write([]byte("Could not render Swagger")) 61 | if err != nil { 62 | log.Error(). 63 | Caller(). 64 | Err(err). 65 | Msg("Failed to write response") 66 | } 67 | 68 | return 69 | } 70 | 71 | writer.Header().Set("Content-Type", "text/html; charset=utf-8") 72 | writer.WriteHeader(http.StatusOK) 73 | _, err := writer.Write(payload.Bytes()) 74 | if err != nil { 75 | log.Error(). 76 | Caller(). 77 | Err(err). 78 | Msg("Failed to write response") 79 | } 80 | } 81 | 82 | func SwaggerAPIv1( 83 | writer http.ResponseWriter, 84 | req *http.Request, 85 | ) { 86 | writer.Header().Set("Content-Type", "application/json; charset=utf-8") 87 | writer.WriteHeader(http.StatusOK) 88 | if _, err := writer.Write(apiV1JSON); err != nil { 89 | log.Error(). 90 | Caller(). 91 | Err(err). 92 | Msg("Failed to write response") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | golangci-lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 2 16 | - name: Get changed files 17 | id: changed-files 18 | uses: dorny/paths-filter@v3 19 | with: 20 | filters: | 21 | files: 22 | - '*.nix' 23 | - 'go.*' 24 | - '**/*.go' 25 | - 'integration_test/' 26 | - 'config-example.yaml' 27 | - uses: DeterminateSystems/nix-installer-action@main 28 | if: steps.changed-files.outputs.files == 'true' 29 | - uses: DeterminateSystems/magic-nix-cache-action@main 30 | if: steps.changed-files.outputs.files == 'true' 31 | 32 | - name: golangci-lint 33 | if: steps.changed-files.outputs.files == 'true' 34 | run: nix develop --command -- golangci-lint run --new-from-rev=${{github.event.pull_request.base.sha}} --out-format=colored-line-number 35 | 36 | prettier-lint: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 2 42 | - name: Get changed files 43 | id: changed-files 44 | uses: dorny/paths-filter@v3 45 | with: 46 | filters: | 47 | files: 48 | - '*.nix' 49 | - '**/*.md' 50 | - '**/*.yml' 51 | - '**/*.yaml' 52 | - '**/*.ts' 53 | - '**/*.js' 54 | - '**/*.sass' 55 | - '**/*.css' 56 | - '**/*.scss' 57 | - '**/*.html' 58 | - uses: DeterminateSystems/nix-installer-action@main 59 | if: steps.changed-files.outputs.files == 'true' 60 | - uses: DeterminateSystems/magic-nix-cache-action@main 61 | if: steps.changed-files.outputs.files == 'true' 62 | 63 | - name: Prettify code 64 | if: steps.changed-files.outputs.files == 'true' 65 | run: nix develop --command -- prettier --no-error-on-unmatched-pattern --ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html} 66 | 67 | proto-lint: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: DeterminateSystems/nix-installer-action@main 72 | - uses: DeterminateSystems/magic-nix-cache-action@main 73 | 74 | - name: Buf lint 75 | run: nix develop --command -- buf lint proto 76 | -------------------------------------------------------------------------------- /hscontrol/util/addr_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/netip" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "go4.org/netipx" 9 | ) 10 | 11 | func Test_parseIPSet(t *testing.T) { 12 | set := func(ips []string, prefixes []string) *netipx.IPSet { 13 | var builder netipx.IPSetBuilder 14 | 15 | for _, ip := range ips { 16 | builder.Add(netip.MustParseAddr(ip)) 17 | } 18 | 19 | for _, pre := range prefixes { 20 | builder.AddPrefix(netip.MustParsePrefix(pre)) 21 | } 22 | 23 | s, _ := builder.IPSet() 24 | 25 | return s 26 | } 27 | 28 | type args struct { 29 | arg string 30 | bits *int 31 | } 32 | tests := []struct { 33 | name string 34 | args args 35 | want *netipx.IPSet 36 | wantErr bool 37 | }{ 38 | { 39 | name: "simple ip4", 40 | args: args{ 41 | arg: "10.0.0.1", 42 | bits: nil, 43 | }, 44 | want: set([]string{ 45 | "10.0.0.1", 46 | }, []string{}), 47 | wantErr: false, 48 | }, 49 | { 50 | name: "simple ip6", 51 | args: args{ 52 | arg: "2001:db8:abcd:1234::2", 53 | bits: nil, 54 | }, 55 | want: set([]string{ 56 | "2001:db8:abcd:1234::2", 57 | }, []string{}), 58 | wantErr: false, 59 | }, 60 | { 61 | name: "wildcard", 62 | args: args{ 63 | arg: "*", 64 | bits: nil, 65 | }, 66 | want: set([]string{}, []string{ 67 | "0.0.0.0/0", 68 | "::/0", 69 | }), 70 | wantErr: false, 71 | }, 72 | { 73 | name: "prefix4", 74 | args: args{ 75 | arg: "192.168.0.0/16", 76 | bits: nil, 77 | }, 78 | want: set([]string{}, []string{ 79 | "192.168.0.0/16", 80 | }), 81 | wantErr: false, 82 | }, 83 | { 84 | name: "prefix6", 85 | args: args{ 86 | arg: "2001:db8:abcd:1234::/64", 87 | bits: nil, 88 | }, 89 | want: set([]string{}, []string{ 90 | "2001:db8:abcd:1234::/64", 91 | }), 92 | wantErr: false, 93 | }, 94 | { 95 | name: "range4", 96 | args: args{ 97 | arg: "192.168.0.0-192.168.255.255", 98 | bits: nil, 99 | }, 100 | want: set([]string{}, []string{ 101 | "192.168.0.0/16", 102 | }), 103 | wantErr: false, 104 | }, 105 | } 106 | for _, tt := range tests { 107 | t.Run(tt.name, func(t *testing.T) { 108 | got, err := ParseIPSet(tt.args.arg, tt.args.bits) 109 | if (err != nil) != tt.wantErr { 110 | t.Errorf("parseIPSet() error = %v, wantErr %v", err, tt.wantErr) 111 | 112 | return 113 | } 114 | if diff := cmp.Diff(tt.want, got); diff != "" { 115 | t.Errorf("parseIPSet() = (-want +got):\n%s", diff) 116 | } 117 | }) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /integration/dockertestutil/execute.go: -------------------------------------------------------------------------------- 1 | package dockertestutil 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/ory/dockertest/v3" 10 | ) 11 | 12 | const dockerExecuteTimeout = time.Second * 30 13 | 14 | var ( 15 | ErrDockertestCommandFailed = errors.New("dockertest command failed") 16 | ErrDockertestCommandTimeout = errors.New("dockertest command timed out") 17 | ) 18 | 19 | type ExecuteCommandConfig struct { 20 | timeout time.Duration 21 | } 22 | 23 | type ExecuteCommandOption func(*ExecuteCommandConfig) error 24 | 25 | func ExecuteCommandTimeout(timeout time.Duration) ExecuteCommandOption { 26 | return ExecuteCommandOption(func(conf *ExecuteCommandConfig) error { 27 | conf.timeout = timeout 28 | return nil 29 | }) 30 | } 31 | 32 | func ExecuteCommand( 33 | resource *dockertest.Resource, 34 | cmd []string, 35 | env []string, 36 | options ...ExecuteCommandOption, 37 | ) (string, string, error) { 38 | var stdout bytes.Buffer 39 | var stderr bytes.Buffer 40 | 41 | execConfig := ExecuteCommandConfig{ 42 | timeout: dockerExecuteTimeout, 43 | } 44 | 45 | for _, opt := range options { 46 | if err := opt(&execConfig); err != nil { 47 | return "", "", fmt.Errorf("execute-command/options: %w", err) 48 | } 49 | } 50 | 51 | type result struct { 52 | exitCode int 53 | err error 54 | } 55 | 56 | resultChan := make(chan result, 1) 57 | 58 | // Run your long running function in it's own goroutine and pass back it's 59 | // response into our channel. 60 | go func() { 61 | exitCode, err := resource.Exec( 62 | cmd, 63 | dockertest.ExecOptions{ 64 | Env: append(env, "HEADSCALE_LOG_LEVEL=info"), 65 | StdOut: &stdout, 66 | StdErr: &stderr, 67 | }, 68 | ) 69 | 70 | resultChan <- result{exitCode, err} 71 | }() 72 | 73 | // Listen on our channel AND a timeout channel - which ever happens first. 74 | select { 75 | case res := <-resultChan: 76 | if res.err != nil { 77 | return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), res.err) 78 | } 79 | 80 | if res.exitCode != 0 { 81 | // Uncomment for debugging 82 | // log.Println("Command: ", cmd) 83 | // log.Println("stdout: ", stdout.String()) 84 | // log.Println("stderr: ", stderr.String()) 85 | 86 | return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), ErrDockertestCommandFailed) 87 | } 88 | 89 | return stdout.String(), stderr.String(), nil 90 | case <-time.After(execConfig.timeout): 91 | return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), ErrDockertestCommandTimeout) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /hscontrol/util/log.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/log" 10 | "gorm.io/gorm" 11 | gormLogger "gorm.io/gorm/logger" 12 | "tailscale.com/types/logger" 13 | ) 14 | 15 | func LogErr(err error, msg string) { 16 | log.Error().Caller().Err(err).Msg(msg) 17 | } 18 | 19 | func TSLogfWrapper() logger.Logf { 20 | return func(format string, args ...any) { 21 | log.Debug().Caller().Msgf(format, args...) 22 | } 23 | } 24 | 25 | type DBLogWrapper struct { 26 | Logger *zerolog.Logger 27 | Level zerolog.Level 28 | Event *zerolog.Event 29 | SlowThreshold time.Duration 30 | SkipErrRecordNotFound bool 31 | ParameterizedQueries bool 32 | } 33 | 34 | func NewDBLogWrapper(origin *zerolog.Logger, slowThreshold time.Duration, skipErrRecordNotFound bool, parameterizedQueries bool) *DBLogWrapper { 35 | l := &DBLogWrapper{ 36 | Logger: origin, 37 | Level: origin.GetLevel(), 38 | SlowThreshold: slowThreshold, 39 | SkipErrRecordNotFound: skipErrRecordNotFound, 40 | ParameterizedQueries: parameterizedQueries, 41 | } 42 | 43 | return l 44 | } 45 | 46 | type DBLogWrapperOption func(*DBLogWrapper) 47 | 48 | func (l *DBLogWrapper) LogMode(gormLogger.LogLevel) gormLogger.Interface { 49 | return l 50 | } 51 | 52 | func (l *DBLogWrapper) Info(ctx context.Context, msg string, data ...interface{}) { 53 | l.Logger.Info().Msgf(msg, data...) 54 | } 55 | 56 | func (l *DBLogWrapper) Warn(ctx context.Context, msg string, data ...interface{}) { 57 | l.Logger.Warn().Msgf(msg, data...) 58 | } 59 | 60 | func (l *DBLogWrapper) Error(ctx context.Context, msg string, data ...interface{}) { 61 | l.Logger.Error().Msgf(msg, data...) 62 | } 63 | 64 | func (l *DBLogWrapper) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { 65 | elapsed := time.Since(begin) 66 | sql, rowsAffected := fc() 67 | fields := map[string]interface{}{ 68 | "duration": elapsed, 69 | "sql": sql, 70 | "rowsAffected": rowsAffected, 71 | } 72 | 73 | if err != nil && !(errors.Is(err, gorm.ErrRecordNotFound) && l.SkipErrRecordNotFound) { 74 | l.Logger.Error().Err(err).Fields(fields).Msgf("") 75 | return 76 | } 77 | 78 | if l.SlowThreshold != 0 && elapsed > l.SlowThreshold { 79 | l.Logger.Warn().Fields(fields).Msgf("") 80 | return 81 | } 82 | 83 | l.Logger.Debug().Fields(fields).Msgf("") 84 | } 85 | 86 | func (l *DBLogWrapper) ParamsFilter(ctx context.Context, sql string, params ...interface{}) (string, []interface{}) { 87 | if l.ParameterizedQueries { 88 | return sql, nil 89 | } 90 | return sql, params 91 | } 92 | -------------------------------------------------------------------------------- /docs/packaging/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Determine OS platform 3 | # shellcheck source=/dev/null 4 | . /etc/os-release 5 | 6 | HEADSCALE_EXE="/usr/bin/headscale" 7 | BSD_HIER="" 8 | HEADSCALE_RUN_DIR="/var/run/headscale" 9 | HEADSCALE_HOME_DIR="/var/lib/headscale" 10 | HEADSCALE_USER="headscale" 11 | HEADSCALE_GROUP="headscale" 12 | HEADSCALE_SHELL="/usr/sbin/nologin" 13 | 14 | ensure_sudo() { 15 | if [ "$(id -u)" = "0" ]; then 16 | echo "Sudo permissions detected" 17 | else 18 | echo "No sudo permission detected, please run as sudo" 19 | exit 1 20 | fi 21 | } 22 | 23 | ensure_headscale_path() { 24 | if [ ! -f "$HEADSCALE_EXE" ]; then 25 | echo "headscale not in default path, exiting..." 26 | exit 1 27 | fi 28 | 29 | printf "Found headscale %s\n" "$HEADSCALE_EXE" 30 | } 31 | 32 | create_headscale_user() { 33 | printf "PostInstall: Adding headscale user %s\n" "$HEADSCALE_USER" 34 | useradd -s "$HEADSCALE_SHELL" -d "$HEADSCALE_HOME_DIR" -c "headscale default user" "$HEADSCALE_USER" 35 | } 36 | 37 | create_headscale_group() { 38 | if command -V systemctl >/dev/null 2>&1; then 39 | printf "PostInstall: Adding headscale group %s\n" "$HEADSCALE_GROUP" 40 | groupadd "$HEADSCALE_GROUP" 41 | 42 | printf "PostInstall: Adding headscale user %s to group %s\n" "$HEADSCALE_USER" "$HEADSCALE_GROUP" 43 | usermod -a -G "$HEADSCALE_GROUP" "$HEADSCALE_USER" 44 | fi 45 | 46 | if [ "$ID" = "alpine" ]; then 47 | printf "PostInstall: Adding headscale group %s\n" "$HEADSCALE_GROUP" 48 | addgroup "$HEADSCALE_GROUP" 49 | 50 | printf "PostInstall: Adding headscale user %s to group %s\n" "$HEADSCALE_USER" "$HEADSCALE_GROUP" 51 | addgroup "$HEADSCALE_USER" "$HEADSCALE_GROUP" 52 | fi 53 | } 54 | 55 | create_run_dir() { 56 | printf "PostInstall: Creating headscale run directory \n" 57 | mkdir -p "$HEADSCALE_RUN_DIR" 58 | 59 | printf "PostInstall: Modifying group ownership of headscale run directory \n" 60 | chown "$HEADSCALE_USER":"$HEADSCALE_GROUP" "$HEADSCALE_RUN_DIR" 61 | } 62 | 63 | summary() { 64 | echo "----------------------------------------------------------------------" 65 | echo " headscale package has been successfully installed." 66 | echo "" 67 | echo " Please follow the next steps to start the software:" 68 | echo "" 69 | echo " sudo systemctl enable headscale" 70 | echo " sudo systemctl start headscale" 71 | echo "" 72 | echo " Configuration settings can be adjusted here:" 73 | echo " ${BSD_HIER}/etc/headscale/config.yaml" 74 | echo "" 75 | echo "----------------------------------------------------------------------" 76 | } 77 | 78 | # 79 | # Main body of the script 80 | # 81 | { 82 | ensure_sudo 83 | ensure_headscale_path 84 | create_headscale_user 85 | create_headscale_group 86 | create_run_dir 87 | summary 88 | } 89 | -------------------------------------------------------------------------------- /hscontrol/db/api_key_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | 6 | "gopkg.in/check.v1" 7 | ) 8 | 9 | func (*Suite) TestCreateAPIKey(c *check.C) { 10 | apiKeyStr, apiKey, err := db.CreateAPIKey(nil) 11 | c.Assert(err, check.IsNil) 12 | c.Assert(apiKey, check.NotNil) 13 | 14 | // Did we get a valid key? 15 | c.Assert(apiKey.Prefix, check.NotNil) 16 | c.Assert(apiKey.Hash, check.NotNil) 17 | c.Assert(apiKeyStr, check.Not(check.Equals), "") 18 | 19 | _, err = db.ListAPIKeys() 20 | c.Assert(err, check.IsNil) 21 | 22 | keys, err := db.ListAPIKeys() 23 | c.Assert(err, check.IsNil) 24 | c.Assert(len(keys), check.Equals, 1) 25 | } 26 | 27 | func (*Suite) TestAPIKeyDoesNotExist(c *check.C) { 28 | key, err := db.GetAPIKey("does-not-exist") 29 | c.Assert(err, check.NotNil) 30 | c.Assert(key, check.IsNil) 31 | } 32 | 33 | func (*Suite) TestValidateAPIKeyOk(c *check.C) { 34 | nowPlus2 := time.Now().Add(2 * time.Hour) 35 | apiKeyStr, apiKey, err := db.CreateAPIKey(&nowPlus2) 36 | c.Assert(err, check.IsNil) 37 | c.Assert(apiKey, check.NotNil) 38 | 39 | valid, err := db.ValidateAPIKey(apiKeyStr) 40 | c.Assert(err, check.IsNil) 41 | c.Assert(valid, check.Equals, true) 42 | } 43 | 44 | func (*Suite) TestValidateAPIKeyNotOk(c *check.C) { 45 | nowMinus2 := time.Now().Add(time.Duration(-2) * time.Hour) 46 | apiKeyStr, apiKey, err := db.CreateAPIKey(&nowMinus2) 47 | c.Assert(err, check.IsNil) 48 | c.Assert(apiKey, check.NotNil) 49 | 50 | valid, err := db.ValidateAPIKey(apiKeyStr) 51 | c.Assert(err, check.IsNil) 52 | c.Assert(valid, check.Equals, false) 53 | 54 | now := time.Now() 55 | apiKeyStrNow, apiKey, err := db.CreateAPIKey(&now) 56 | c.Assert(err, check.IsNil) 57 | c.Assert(apiKey, check.NotNil) 58 | 59 | validNow, err := db.ValidateAPIKey(apiKeyStrNow) 60 | c.Assert(err, check.IsNil) 61 | c.Assert(validNow, check.Equals, false) 62 | 63 | validSilly, err := db.ValidateAPIKey("nota.validkey") 64 | c.Assert(err, check.NotNil) 65 | c.Assert(validSilly, check.Equals, false) 66 | 67 | validWithErr, err := db.ValidateAPIKey("produceerrorkey") 68 | c.Assert(err, check.NotNil) 69 | c.Assert(validWithErr, check.Equals, false) 70 | } 71 | 72 | func (*Suite) TestExpireAPIKey(c *check.C) { 73 | nowPlus2 := time.Now().Add(2 * time.Hour) 74 | apiKeyStr, apiKey, err := db.CreateAPIKey(&nowPlus2) 75 | c.Assert(err, check.IsNil) 76 | c.Assert(apiKey, check.NotNil) 77 | 78 | valid, err := db.ValidateAPIKey(apiKeyStr) 79 | c.Assert(err, check.IsNil) 80 | c.Assert(valid, check.Equals, true) 81 | 82 | err = db.ExpireAPIKey(apiKey) 83 | c.Assert(err, check.IsNil) 84 | c.Assert(apiKey.Expiration, check.NotNil) 85 | 86 | notValid, err := db.ValidateAPIKey(apiKeyStr) 87 | c.Assert(err, check.IsNil) 88 | c.Assert(notValid, check.Equals, false) 89 | } 90 | -------------------------------------------------------------------------------- /cmd/headscale/cli/policy.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1" 9 | "github.com/rs/zerolog/log" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func init() { 14 | rootCmd.AddCommand(policyCmd) 15 | policyCmd.AddCommand(getPolicy) 16 | 17 | setPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") 18 | if err := setPolicy.MarkFlagRequired("file"); err != nil { 19 | log.Fatal().Err(err).Msg("") 20 | } 21 | policyCmd.AddCommand(setPolicy) 22 | } 23 | 24 | var policyCmd = &cobra.Command{ 25 | Use: "policy", 26 | Short: "Manage the Headscale ACL Policy", 27 | } 28 | 29 | var getPolicy = &cobra.Command{ 30 | Use: "get", 31 | Short: "Print the current ACL Policy", 32 | Aliases: []string{"show", "view", "fetch"}, 33 | Run: func(cmd *cobra.Command, args []string) { 34 | output, _ := cmd.Flags().GetString("output") 35 | ctx, client, conn, cancel := newHeadscaleCLIWithConfig() 36 | defer cancel() 37 | defer conn.Close() 38 | 39 | request := &v1.GetPolicyRequest{} 40 | 41 | response, err := client.GetPolicy(ctx, request) 42 | if err != nil { 43 | ErrorOutput(err, fmt.Sprintf("Failed loading ACL Policy: %s", err), output) 44 | } 45 | 46 | // TODO(pallabpain): Maybe print this better? 47 | // This does not pass output as we dont support yaml, json or json-line 48 | // output for this command. It is HuJSON already. 49 | SuccessOutput("", response.GetPolicy(), "") 50 | }, 51 | } 52 | 53 | var setPolicy = &cobra.Command{ 54 | Use: "set", 55 | Short: "Updates the ACL Policy", 56 | Long: ` 57 | Updates the existing ACL Policy with the provided policy. The policy must be a valid HuJSON object. 58 | This command only works when the acl.policy_mode is set to "db", and the policy will be stored in the database.`, 59 | Aliases: []string{"put", "update"}, 60 | Run: func(cmd *cobra.Command, args []string) { 61 | output, _ := cmd.Flags().GetString("output") 62 | policyPath, _ := cmd.Flags().GetString("file") 63 | 64 | f, err := os.Open(policyPath) 65 | if err != nil { 66 | ErrorOutput(err, fmt.Sprintf("Error opening the policy file: %s", err), output) 67 | } 68 | defer f.Close() 69 | 70 | policyBytes, err := io.ReadAll(f) 71 | if err != nil { 72 | ErrorOutput(err, fmt.Sprintf("Error reading the policy file: %s", err), output) 73 | } 74 | 75 | request := &v1.SetPolicyRequest{Policy: string(policyBytes)} 76 | 77 | ctx, client, conn, cancel := newHeadscaleCLIWithConfig() 78 | defer cancel() 79 | defer conn.Close() 80 | 81 | if _, err := client.SetPolicy(ctx, request); err != nil { 82 | ErrorOutput(err, fmt.Sprintf("Failed to set ACL Policy: %s", err), output) 83 | } 84 | 85 | SuccessOutput(nil, "Policy updated.", "") 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Headscale is "Open Source, acknowledged contribution", this means that any contribution will have to be discussed with the maintainers before being added to the project. 4 | This model has been chosen to reduce the risk of burnout by limiting the maintenance overhead of reviewing and validating third-party code. 5 | 6 | ## Why do we have this model? 7 | 8 | Headscale has a small maintainer team that tries to balance working on the project, fixing bugs and reviewing contributions. 9 | 10 | When we work on issues ourselves, we develop first hand knowledge of the code and it makes it possible for us to maintain and own the code as the project develops. 11 | 12 | Code contributions are seen as a positive thing. People enjoy and engage with our project, but it also comes with some challenges; we have to understand the code, we have to understand the feature, we might have to become familiar with external libraries or services and we think about security implications. All those steps are required during the reviewing process. After the code has been merged, the feature has to be maintained. Any changes reliant on external services must be updated and expanded accordingly. 13 | 14 | The review and day-1 maintenance adds a significant burden on the maintainers. Often we hope that the contributor will help out, but we found that most of the time, they disappear after their new feature was added. 15 | 16 | This means that when someone contributes, we are mostly happy about it, but we do have to run it through a series of checks to establish if we actually can maintain this feature. 17 | 18 | ## What do we require? 19 | 20 | A general description is provided here and an explicit list is provided in our pull request template. 21 | 22 | All new features have to start out with a design document, which should be discussed on the issue tracker (not discord). It should include a use case for the feature, how it can be implemented, who will implement it and a plan for maintaining it. 23 | 24 | All features have to be end-to-end tested (integration tests) and have good unit test coverage to ensure that they work as expected. This will also ensure that the feature continues to work as expected over time. If a change cannot be tested, a strong case for why this is not possible needs to be presented. 25 | 26 | The contributor should help to maintain the feature over time. In case the feature is not maintained probably, the maintainers reserve themselves the right to remove features they redeem as unmaintainable. This should help to improve the quality of the software and keep it in a maintainable state. 27 | 28 | ## Bug fixes 29 | 30 | Headscale is open to code contributions for bug fixes without discussion. 31 | 32 | ## Documentation 33 | 34 | If you find mistakes in the documentation, please submit a fix to the documentation. 35 | -------------------------------------------------------------------------------- /hscontrol/db/preauth_keys_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/juanfont/headscale/hscontrol/types" 8 | "github.com/juanfont/headscale/hscontrol/util" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "tailscale.com/types/ptr" 12 | 13 | "gopkg.in/check.v1" 14 | ) 15 | 16 | func (*Suite) TestCreatePreAuthKey(c *check.C) { 17 | // ID does not exist 18 | _, err := db.CreatePreAuthKey(12345, true, false, nil, nil) 19 | c.Assert(err, check.NotNil) 20 | 21 | user, err := db.CreateUser(types.User{Name: "test"}) 22 | c.Assert(err, check.IsNil) 23 | 24 | key, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil) 25 | c.Assert(err, check.IsNil) 26 | 27 | // Did we get a valid key? 28 | c.Assert(key.Key, check.NotNil) 29 | c.Assert(len(key.Key), check.Equals, 48) 30 | 31 | // Make sure the User association is populated 32 | c.Assert(key.User.ID, check.Equals, user.ID) 33 | 34 | // ID does not exist 35 | _, err = db.ListPreAuthKeys(1000000) 36 | c.Assert(err, check.NotNil) 37 | 38 | keys, err := db.ListPreAuthKeys(types.UserID(user.ID)) 39 | c.Assert(err, check.IsNil) 40 | c.Assert(len(keys), check.Equals, 1) 41 | 42 | // Make sure the User association is populated 43 | c.Assert((keys)[0].User.ID, check.Equals, user.ID) 44 | } 45 | 46 | func (*Suite) TestPreAuthKeyACLTags(c *check.C) { 47 | user, err := db.CreateUser(types.User{Name: "test8"}) 48 | c.Assert(err, check.IsNil) 49 | 50 | _, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"badtag"}) 51 | c.Assert(err, check.NotNil) // Confirm that malformed tags are rejected 52 | 53 | tags := []string{"tag:test1", "tag:test2"} 54 | tagsWithDuplicate := []string{"tag:test1", "tag:test2", "tag:test2"} 55 | _, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, tagsWithDuplicate) 56 | c.Assert(err, check.IsNil) 57 | 58 | listedPaks, err := db.ListPreAuthKeys(types.UserID(user.ID)) 59 | c.Assert(err, check.IsNil) 60 | gotTags := listedPaks[0].Proto().GetAclTags() 61 | sort.Sort(sort.StringSlice(gotTags)) 62 | c.Assert(gotTags, check.DeepEquals, tags) 63 | } 64 | 65 | func TestCannotDeleteAssignedPreAuthKey(t *testing.T) { 66 | db, err := newSQLiteTestDB() 67 | require.NoError(t, err) 68 | user, err := db.CreateUser(types.User{Name: "test8"}) 69 | assert.NoError(t, err) 70 | 71 | key, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"tag:good"}) 72 | assert.NoError(t, err) 73 | 74 | node := types.Node{ 75 | ID: 0, 76 | Hostname: "testest", 77 | UserID: user.ID, 78 | RegisterMethod: util.RegisterMethodAuthKey, 79 | AuthKeyID: ptr.To(key.ID), 80 | } 81 | db.DB.Save(&node) 82 | 83 | err = db.DB.Delete(key).Error 84 | require.ErrorContains(t, err, "constraint failed: FOREIGN KEY constraint failed") 85 | } 86 | -------------------------------------------------------------------------------- /hscontrol/db/suite_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/url" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/juanfont/headscale/hscontrol/types" 13 | "gopkg.in/check.v1" 14 | "zombiezen.com/go/postgrestest" 15 | ) 16 | 17 | func Test(t *testing.T) { 18 | check.TestingT(t) 19 | } 20 | 21 | var _ = check.Suite(&Suite{}) 22 | 23 | type Suite struct{} 24 | 25 | var ( 26 | tmpDir string 27 | db *HSDatabase 28 | ) 29 | 30 | func (s *Suite) SetUpTest(c *check.C) { 31 | s.ResetDB(c) 32 | } 33 | 34 | func (s *Suite) TearDownTest(c *check.C) { 35 | // os.RemoveAll(tmpDir) 36 | } 37 | 38 | func (s *Suite) ResetDB(c *check.C) { 39 | // if len(tmpDir) != 0 { 40 | // os.RemoveAll(tmpDir) 41 | // } 42 | 43 | var err error 44 | db, err = newSQLiteTestDB() 45 | if err != nil { 46 | c.Fatal(err) 47 | } 48 | } 49 | 50 | // TODO(kradalby): make this a t.Helper when we dont depend 51 | // on check test framework. 52 | func newSQLiteTestDB() (*HSDatabase, error) { 53 | var err error 54 | tmpDir, err = os.MkdirTemp("", "headscale-db-test-*") 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | log.Printf("database path: %s", tmpDir+"/headscale_test.db") 60 | 61 | db, err = NewHeadscaleDatabase( 62 | types.DatabaseConfig{ 63 | Type: types.DatabaseSqlite, 64 | Sqlite: types.SqliteConfig{ 65 | Path: tmpDir + "/headscale_test.db", 66 | }, 67 | }, 68 | "", 69 | emptyCache(), 70 | ) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return db, nil 76 | } 77 | 78 | func newPostgresTestDB(t *testing.T) *HSDatabase { 79 | t.Helper() 80 | 81 | return newHeadscaleDBFromPostgresURL(t, newPostgresDBForTest(t)) 82 | } 83 | 84 | func newPostgresDBForTest(t *testing.T) *url.URL { 85 | t.Helper() 86 | 87 | ctx := context.Background() 88 | srv, err := postgrestest.Start(ctx) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | t.Cleanup(srv.Cleanup) 93 | 94 | u, err := srv.CreateDatabase(ctx) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | t.Logf("created local postgres: %s", u) 99 | pu, _ := url.Parse(u) 100 | 101 | return pu 102 | } 103 | 104 | func newHeadscaleDBFromPostgresURL(t *testing.T, pu *url.URL) *HSDatabase { 105 | t.Helper() 106 | 107 | pass, _ := pu.User.Password() 108 | port, _ := strconv.Atoi(pu.Port()) 109 | 110 | db, err := NewHeadscaleDatabase( 111 | types.DatabaseConfig{ 112 | Type: types.DatabasePostgres, 113 | Postgres: types.PostgresConfig{ 114 | Host: pu.Hostname(), 115 | User: pu.User.Username(), 116 | Name: strings.TrimLeft(pu.Path, "/"), 117 | Pass: pass, 118 | Port: port, 119 | Ssl: "disable", 120 | }, 121 | }, 122 | "", 123 | emptyCache(), 124 | ) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | 129 | return db 130 | } 131 | -------------------------------------------------------------------------------- /proto/headscale/v1/node.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package headscale.v1; 3 | 4 | import "google/protobuf/timestamp.proto"; 5 | import "headscale/v1/preauthkey.proto"; 6 | import "headscale/v1/user.proto"; 7 | 8 | option go_package = "github.com/juanfont/headscale/gen/go/v1"; 9 | 10 | enum RegisterMethod { 11 | REGISTER_METHOD_UNSPECIFIED = 0; 12 | REGISTER_METHOD_AUTH_KEY = 1; 13 | REGISTER_METHOD_CLI = 2; 14 | REGISTER_METHOD_OIDC = 3; 15 | } 16 | 17 | message Node { 18 | // 9: removal of last_successful_update 19 | reserved 9; 20 | 21 | uint64 id = 1; 22 | string machine_key = 2; 23 | string node_key = 3; 24 | string disco_key = 4; 25 | repeated string ip_addresses = 5; 26 | string name = 6; 27 | User user = 7; 28 | 29 | google.protobuf.Timestamp last_seen = 8; 30 | google.protobuf.Timestamp expiry = 10; 31 | 32 | PreAuthKey pre_auth_key = 11; 33 | 34 | google.protobuf.Timestamp created_at = 12; 35 | 36 | RegisterMethod register_method = 13; 37 | 38 | reserved 14 to 17; 39 | // google.protobuf.Timestamp updated_at = 14; 40 | // google.protobuf.Timestamp deleted_at = 15; 41 | 42 | // bytes host_info = 15; 43 | // bytes endpoints = 16; 44 | // bytes enabled_routes = 17; 45 | 46 | repeated string forced_tags = 18; 47 | repeated string invalid_tags = 19; 48 | repeated string valid_tags = 20; 49 | string given_name = 21; 50 | bool online = 22; 51 | } 52 | 53 | message RegisterNodeRequest { 54 | string user = 1; 55 | string key = 2; 56 | } 57 | 58 | message RegisterNodeResponse { Node node = 1; } 59 | 60 | message GetNodeRequest { uint64 node_id = 1; } 61 | 62 | message GetNodeResponse { Node node = 1; } 63 | 64 | message SetTagsRequest { 65 | uint64 node_id = 1; 66 | repeated string tags = 2; 67 | } 68 | 69 | message SetTagsResponse { Node node = 1; } 70 | 71 | message DeleteNodeRequest { uint64 node_id = 1; } 72 | 73 | message DeleteNodeResponse {} 74 | 75 | message ExpireNodeRequest { uint64 node_id = 1; } 76 | 77 | message ExpireNodeResponse { Node node = 1; } 78 | 79 | message RenameNodeRequest { 80 | uint64 node_id = 1; 81 | string new_name = 2; 82 | } 83 | 84 | message RenameNodeResponse { Node node = 1; } 85 | 86 | message ListNodesRequest { string user = 1; } 87 | 88 | message ListNodesResponse { repeated Node nodes = 1; } 89 | 90 | message MoveNodeRequest { 91 | uint64 node_id = 1; 92 | string user = 2; 93 | } 94 | 95 | message MoveNodeResponse { Node node = 1; } 96 | 97 | message DebugCreateNodeRequest { 98 | string user = 1; 99 | string key = 2; 100 | string name = 3; 101 | repeated string routes = 4; 102 | } 103 | 104 | message DebugCreateNodeResponse { Node node = 1; } 105 | 106 | message BackfillNodeIPsRequest { bool confirmed = 1; } 107 | 108 | message BackfillNodeIPsResponse { repeated string changes = 1; } 109 | -------------------------------------------------------------------------------- /integration/derp_verify_endpoint_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/juanfont/headscale/hscontrol/util" 12 | "github.com/juanfont/headscale/integration/dsic" 13 | "github.com/juanfont/headscale/integration/hsic" 14 | "github.com/juanfont/headscale/integration/integrationutil" 15 | "github.com/juanfont/headscale/integration/tsic" 16 | "tailscale.com/tailcfg" 17 | ) 18 | 19 | func TestDERPVerifyEndpoint(t *testing.T) { 20 | IntegrationSkip(t) 21 | 22 | // Generate random hostname for the headscale instance 23 | hash, err := util.GenerateRandomStringDNSSafe(6) 24 | assertNoErr(t, err) 25 | testName := "derpverify" 26 | hostname := fmt.Sprintf("hs-%s-%s", testName, hash) 27 | 28 | headscalePort := 8080 29 | 30 | // Create cert for headscale 31 | certHeadscale, keyHeadscale, err := integrationutil.CreateCertificate(hostname) 32 | assertNoErr(t, err) 33 | 34 | scenario, err := NewScenario(dockertestMaxWait()) 35 | assertNoErr(t, err) 36 | defer scenario.ShutdownAssertNoPanics(t) 37 | 38 | spec := map[string]int{ 39 | "user1": len(MustTestVersions), 40 | } 41 | 42 | derper, err := scenario.CreateDERPServer("head", 43 | dsic.WithCACert(certHeadscale), 44 | dsic.WithVerifyClientURL(fmt.Sprintf("https://%s/verify", net.JoinHostPort(hostname, strconv.Itoa(headscalePort)))), 45 | ) 46 | assertNoErr(t, err) 47 | 48 | derpMap := tailcfg.DERPMap{ 49 | Regions: map[int]*tailcfg.DERPRegion{ 50 | 900: { 51 | RegionID: 900, 52 | RegionCode: "test-derpverify", 53 | RegionName: "TestDerpVerify", 54 | Nodes: []*tailcfg.DERPNode{ 55 | { 56 | Name: "TestDerpVerify", 57 | RegionID: 900, 58 | HostName: derper.GetHostname(), 59 | STUNPort: derper.GetSTUNPort(), 60 | STUNOnly: false, 61 | DERPPort: derper.GetDERPPort(), 62 | }, 63 | }, 64 | }, 65 | }, 66 | } 67 | 68 | err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{tsic.WithCACert(derper.GetCert())}, 69 | hsic.WithHostname(hostname), 70 | hsic.WithPort(headscalePort), 71 | hsic.WithCustomTLS(certHeadscale, keyHeadscale), 72 | hsic.WithDERPConfig(derpMap)) 73 | assertNoErrHeadscaleEnv(t, err) 74 | 75 | allClients, err := scenario.ListTailscaleClients() 76 | assertNoErrListClients(t, err) 77 | 78 | for _, client := range allClients { 79 | report, err := client.DebugDERPRegion("test-derpverify") 80 | assertNoErr(t, err) 81 | successful := false 82 | for _, line := range report.Info { 83 | if strings.Contains(line, "Successfully established a DERP connection with node") { 84 | successful = true 85 | 86 | break 87 | } 88 | } 89 | if !successful { 90 | stJSON, err := json.Marshal(report) 91 | assertNoErr(t, err) 92 | t.Errorf("Client %s could not establish a DERP connection: %s", client.Hostname(), string(stJSON)) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /hscontrol/capver/capver.go: -------------------------------------------------------------------------------- 1 | package capver 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | xmaps "golang.org/x/exp/maps" 8 | "tailscale.com/tailcfg" 9 | "tailscale.com/util/set" 10 | ) 11 | 12 | const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 88 13 | 14 | // CanOldCodeBeCleanedUp is intended to be called on startup to see if 15 | // there are old code that can ble cleaned up, entries should contain 16 | // a CapVer where something can be cleaned up and a panic if it can. 17 | // This is only intended to catch things in tests. 18 | // 19 | // All uses of Capability version checks should be listed here. 20 | func CanOldCodeBeCleanedUp() { 21 | if MinSupportedCapabilityVersion >= 111 { 22 | panic("LegacyDERP can be cleaned up in tail.go") 23 | } 24 | } 25 | 26 | func tailscaleVersSorted() []string { 27 | vers := xmaps.Keys(tailscaleToCapVer) 28 | sort.Strings(vers) 29 | return vers 30 | } 31 | 32 | func capVersSorted() []tailcfg.CapabilityVersion { 33 | capVers := xmaps.Keys(capVerToTailscaleVer) 34 | sort.Slice(capVers, func(i, j int) bool { 35 | return capVers[i] < capVers[j] 36 | }) 37 | return capVers 38 | } 39 | 40 | // TailscaleVersion returns the Tailscale version for the given CapabilityVersion. 41 | func TailscaleVersion(ver tailcfg.CapabilityVersion) string { 42 | return capVerToTailscaleVer[ver] 43 | } 44 | 45 | // CapabilityVersion returns the CapabilityVersion for the given Tailscale version. 46 | func CapabilityVersion(ver string) tailcfg.CapabilityVersion { 47 | if !strings.HasPrefix(ver, "v") { 48 | ver = "v" + ver 49 | } 50 | return tailscaleToCapVer[ver] 51 | } 52 | 53 | // TailscaleLatest returns the n latest Tailscale versions. 54 | func TailscaleLatest(n int) []string { 55 | if n <= 0 { 56 | return nil 57 | } 58 | 59 | tsSorted := tailscaleVersSorted() 60 | 61 | if n > len(tsSorted) { 62 | return tsSorted 63 | } 64 | 65 | return tsSorted[len(tsSorted)-n:] 66 | } 67 | 68 | // TailscaleLatestMajorMinor returns the n latest Tailscale versions (e.g. 1.80). 69 | func TailscaleLatestMajorMinor(n int, stripV bool) []string { 70 | if n <= 0 { 71 | return nil 72 | } 73 | 74 | majors := set.Set[string]{} 75 | for _, vers := range tailscaleVersSorted() { 76 | if stripV { 77 | vers = strings.TrimPrefix(vers, "v") 78 | } 79 | v := strings.Split(vers, ".") 80 | majors.Add(v[0] + "." + v[1]) 81 | } 82 | 83 | majorSl := majors.Slice() 84 | sort.Strings(majorSl) 85 | 86 | if n > len(majorSl) { 87 | return majorSl 88 | } 89 | 90 | return majorSl[len(majorSl)-n:] 91 | } 92 | 93 | // CapVerLatest returns the n latest CapabilityVersions. 94 | func CapVerLatest(n int) []tailcfg.CapabilityVersion { 95 | if n <= 0 { 96 | return nil 97 | } 98 | 99 | s := capVersSorted() 100 | 101 | if n > len(s) { 102 | return s 103 | } 104 | 105 | return s[len(s)-n:] 106 | } 107 | -------------------------------------------------------------------------------- /hscontrol/notifier/metrics.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promauto" 6 | "tailscale.com/envknob" 7 | ) 8 | 9 | const prometheusNamespace = "headscale" 10 | 11 | var debugHighCardinalityMetrics = envknob.Bool("HEADSCALE_DEBUG_HIGH_CARDINALITY_METRICS") 12 | 13 | var notifierUpdateSent *prometheus.CounterVec 14 | 15 | func init() { 16 | if debugHighCardinalityMetrics { 17 | notifierUpdateSent = promauto.NewCounterVec(prometheus.CounterOpts{ 18 | Namespace: prometheusNamespace, 19 | Name: "notifier_update_sent_total", 20 | Help: "total count of update sent on nodes channel", 21 | }, []string{"status", "type", "trigger", "id"}) 22 | } else { 23 | notifierUpdateSent = promauto.NewCounterVec(prometheus.CounterOpts{ 24 | Namespace: prometheusNamespace, 25 | Name: "notifier_update_sent_total", 26 | Help: "total count of update sent on nodes channel", 27 | }, []string{"status", "type", "trigger"}) 28 | } 29 | } 30 | 31 | var ( 32 | notifierWaitersForLock = promauto.NewGaugeVec(prometheus.GaugeOpts{ 33 | Namespace: prometheusNamespace, 34 | Name: "notifier_waiters_for_lock", 35 | Help: "gauge of waiters for the notifier lock", 36 | }, []string{"type", "action"}) 37 | notifierWaitForLock = promauto.NewHistogramVec(prometheus.HistogramOpts{ 38 | Namespace: prometheusNamespace, 39 | Name: "notifier_wait_for_lock_seconds", 40 | Help: "histogram of time spent waiting for the notifier lock", 41 | Buckets: []float64{0.001, 0.01, 0.1, 0.3, 0.5, 1, 3, 5, 10}, 42 | }, []string{"action"}) 43 | notifierUpdateReceived = promauto.NewCounterVec(prometheus.CounterOpts{ 44 | Namespace: prometheusNamespace, 45 | Name: "notifier_update_received_total", 46 | Help: "total count of updates received by notifier", 47 | }, []string{"type", "trigger"}) 48 | notifierNodeUpdateChans = promauto.NewGauge(prometheus.GaugeOpts{ 49 | Namespace: prometheusNamespace, 50 | Name: "notifier_open_channels_total", 51 | Help: "total count open channels in notifier", 52 | }) 53 | notifierBatcherWaitersForLock = promauto.NewGaugeVec(prometheus.GaugeOpts{ 54 | Namespace: prometheusNamespace, 55 | Name: "notifier_batcher_waiters_for_lock", 56 | Help: "gauge of waiters for the notifier batcher lock", 57 | }, []string{"type", "action"}) 58 | notifierBatcherChanges = promauto.NewGaugeVec(prometheus.GaugeOpts{ 59 | Namespace: prometheusNamespace, 60 | Name: "notifier_batcher_changes_pending", 61 | Help: "gauge of full changes pending in the notifier batcher", 62 | }, []string{}) 63 | notifierBatcherPatches = promauto.NewGaugeVec(prometheus.GaugeOpts{ 64 | Namespace: prometheusNamespace, 65 | Name: "notifier_batcher_patches_pending", 66 | Help: "gauge of patches pending in the notifier batcher", 67 | }, []string{}) 68 | ) 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug 2 | description: File a bug/issue 3 | title: "[Bug] <title>" 4 | labels: ["bug", "needs triage"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is this a support request? 9 | description: This issue tracker is for bugs and feature requests only. If you need help, please use ask in our Discord community 10 | options: 11 | - label: This is not a support request 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Is there an existing issue for this? 16 | description: Please search to see if an issue already exists for the bug you encountered. 17 | options: 18 | - label: I have searched the existing issues 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Current Behavior 23 | description: A concise description of what you're experiencing. 24 | validations: 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: Expected Behavior 29 | description: A concise description of what you expected to happen. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Steps To Reproduce 35 | description: Steps to reproduce the behavior. 36 | placeholder: | 37 | 1. In this environment... 38 | 1. With this config... 39 | 1. Run '...' 40 | 1. See error... 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Environment 46 | description: | 47 | examples: 48 | - **OS**: Ubuntu 20.04 49 | - **Headscale version**: 0.22.3 50 | - **Tailscale version**: 1.64.0 51 | value: | 52 | - OS: 53 | - Headscale version: 54 | - Tailscale version: 55 | render: markdown 56 | validations: 57 | required: true 58 | - type: checkboxes 59 | attributes: 60 | label: Runtime environment 61 | options: 62 | - label: Headscale is behind a (reverse) proxy 63 | required: false 64 | - label: Headscale runs in a container 65 | required: false 66 | - type: textarea 67 | attributes: 68 | label: Anything else? 69 | description: | 70 | Links? References? Anything that will give us more context about the issue you are encountering! 71 | 72 | - Client netmap dump (see below) 73 | - ACL configuration 74 | - Headscale configuration 75 | 76 | Dump the netmap of tailscale clients: 77 | `tailscale debug netmap > DESCRIPTIVE_NAME.json` 78 | 79 | Please provide information describing the netmap, which client, which headscale version etc. 80 | 81 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 82 | validations: 83 | required: false 84 | -------------------------------------------------------------------------------- /cmd/headscale/cli/root.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/juanfont/headscale/hscontrol/types" 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | "github.com/tcnksm/go-latest" 14 | ) 15 | 16 | const ( 17 | deprecateNamespaceMessage = "use --user" 18 | ) 19 | 20 | var cfgFile string = "" 21 | 22 | func init() { 23 | if len(os.Args) > 1 && 24 | (os.Args[1] == "version" || os.Args[1] == "mockoidc" || os.Args[1] == "completion") { 25 | return 26 | } 27 | 28 | cobra.OnInitialize(initConfig) 29 | rootCmd.PersistentFlags(). 30 | StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)") 31 | rootCmd.PersistentFlags(). 32 | StringP("output", "o", "", "Output format. Empty for human-readable, 'json', 'json-line' or 'yaml'") 33 | rootCmd.PersistentFlags(). 34 | Bool("force", false, "Disable prompts and forces the execution") 35 | } 36 | 37 | func initConfig() { 38 | if cfgFile == "" { 39 | cfgFile = os.Getenv("HEADSCALE_CONFIG") 40 | } 41 | if cfgFile != "" { 42 | err := types.LoadConfig(cfgFile, true) 43 | if err != nil { 44 | log.Fatal().Caller().Err(err).Msgf("Error loading config file %s", cfgFile) 45 | } 46 | } else { 47 | err := types.LoadConfig("", false) 48 | if err != nil { 49 | log.Fatal().Caller().Err(err).Msgf("Error loading config") 50 | } 51 | } 52 | 53 | machineOutput := HasMachineOutputFlag() 54 | 55 | // If the user has requested a "node" readable format, 56 | // then disable login so the output remains valid. 57 | if machineOutput { 58 | zerolog.SetGlobalLevel(zerolog.Disabled) 59 | } 60 | 61 | // logFormat := viper.GetString("log.format") 62 | // if logFormat == types.JSONLogFormat { 63 | // log.Logger = log.Output(os.Stdout) 64 | // } 65 | 66 | disableUpdateCheck := viper.GetBool("disable_check_updates") 67 | if !disableUpdateCheck && !machineOutput { 68 | if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && 69 | Version != "dev" { 70 | githubTag := &latest.GithubTag{ 71 | Owner: "juanfont", 72 | Repository: "headscale", 73 | } 74 | res, err := latest.Check(githubTag, Version) 75 | if err == nil && res.Outdated { 76 | //nolint 77 | log.Warn().Msgf( 78 | "An updated version of Headscale has been found (%s vs. your current %s). Check it out https://github.com/juanfont/headscale/releases\n", 79 | res.Current, 80 | Version, 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | 87 | var rootCmd = &cobra.Command{ 88 | Use: "headscale", 89 | Short: "headscale - a Tailscale control server", 90 | Long: ` 91 | headscale is an open source implementation of the Tailscale control server 92 | 93 | https://github.com/juanfont/headscale`, 94 | } 95 | 96 | func Execute() { 97 | if err := rootCmd.Execute(); err != nil { 98 | fmt.Fprintln(os.Stderr, err) 99 | os.Exit(1) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /hscontrol/tailsql.go: -------------------------------------------------------------------------------- 1 | package hscontrol 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/tailscale/tailsql/server/tailsql" 10 | "tailscale.com/tsnet" 11 | "tailscale.com/tsweb" 12 | "tailscale.com/types/logger" 13 | ) 14 | 15 | func runTailSQLService(ctx context.Context, logf logger.Logf, stateDir, dbPath string) error { 16 | opts := tailsql.Options{ 17 | Hostname: "tailsql-headscale", 18 | StateDir: stateDir, 19 | Sources: []tailsql.DBSpec{ 20 | { 21 | Source: "headscale", 22 | Label: "headscale - sqlite", 23 | Driver: "sqlite", 24 | URL: fmt.Sprintf("file:%s?mode=ro", dbPath), 25 | Named: map[string]string{ 26 | "schema": `select * from sqlite_schema`, 27 | }, 28 | }, 29 | }, 30 | } 31 | 32 | tsNode := &tsnet.Server{ 33 | Dir: os.ExpandEnv(opts.StateDir), 34 | Hostname: opts.Hostname, 35 | Logf: logger.Discard, 36 | } 37 | // if *doDebugLog { 38 | // tsNode.Logf = logf 39 | // } 40 | defer tsNode.Close() 41 | 42 | logf("Starting tailscale (hostname=%q)", opts.Hostname) 43 | lc, err := tsNode.LocalClient() 44 | if err != nil { 45 | return fmt.Errorf("connect local client: %w", err) 46 | } 47 | opts.LocalClient = lc // for authentication 48 | 49 | // Make sure the Tailscale node starts up. It might not, if it is a new node 50 | // and the user did not provide an auth key. 51 | if st, err := tsNode.Up(ctx); err != nil { 52 | return fmt.Errorf("starting tailscale: %w", err) 53 | } else { 54 | logf("tailscale started, node state %q", st.BackendState) 55 | } 56 | 57 | // Reaching here, we have a running Tailscale node, now we can set up the 58 | // HTTP and/or HTTPS plumbing for TailSQL itself. 59 | tsql, err := tailsql.NewServer(opts) 60 | if err != nil { 61 | return fmt.Errorf("creating tailsql server: %w", err) 62 | } 63 | 64 | lst, err := tsNode.Listen("tcp", ":80") 65 | if err != nil { 66 | return fmt.Errorf("listen port 80: %w", err) 67 | } 68 | 69 | if opts.ServeHTTPS { 70 | // When serving TLS, add a redirect from HTTP on port 80 to HTTPS on 443. 71 | certDomains := tsNode.CertDomains() 72 | if len(certDomains) == 0 { 73 | return fmt.Errorf("no cert domains available for HTTPS") 74 | } 75 | base := "https://" + certDomains[0] 76 | go http.Serve(lst, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 | target := base + r.RequestURI 78 | http.Redirect(w, r, target, http.StatusPermanentRedirect) 79 | })) 80 | // log.Printf("Redirecting HTTP to HTTPS at %q", base) 81 | 82 | // For the real service, start a separate listener. 83 | // Note: Replaces the port 80 listener. 84 | var err error 85 | lst, err = tsNode.ListenTLS("tcp", ":443") 86 | if err != nil { 87 | return fmt.Errorf("listen TLS: %w", err) 88 | } 89 | logf("enabled serving via HTTPS") 90 | } 91 | 92 | mux := tsql.NewMux() 93 | tsweb.Debugger(mux) 94 | go http.Serve(lst, mux) 95 | logf("ailSQL started") 96 | <-ctx.Done() 97 | logf("TailSQL shutting down...") 98 | return tsNode.Close() 99 | } 100 | -------------------------------------------------------------------------------- /hscontrol/util/addr.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "net/netip" 6 | "strings" 7 | 8 | "go4.org/netipx" 9 | ) 10 | 11 | // This is borrowed from, and updated to use IPSet 12 | // https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162 13 | // TODO(kradalby): contribute upstream and make public. 14 | var ( 15 | zeroIP4 = netip.AddrFrom4([4]byte{}) 16 | zeroIP6 = netip.AddrFrom16([16]byte{}) 17 | ) 18 | 19 | // parseIPSet parses arg as one: 20 | // 21 | // - an IP address (IPv4 or IPv6) 22 | // - the string "*" to match everything (both IPv4 & IPv6) 23 | // - a CIDR (e.g. "192.168.0.0/16") 24 | // - a range of two IPs, inclusive, separated by hyphen ("2eff::1-2eff::0800") 25 | // 26 | // bits, if non-nil, is the legacy SrcBits CIDR length to make a IP 27 | // address (without a slash) treated as a CIDR of *bits length. 28 | // nolint 29 | func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) { 30 | var ipSet netipx.IPSetBuilder 31 | if arg == "*" { 32 | ipSet.AddPrefix(netip.PrefixFrom(zeroIP4, 0)) 33 | ipSet.AddPrefix(netip.PrefixFrom(zeroIP6, 0)) 34 | 35 | return ipSet.IPSet() 36 | } 37 | if strings.Contains(arg, "/") { 38 | pfx, err := netip.ParsePrefix(arg) 39 | if err != nil { 40 | return nil, err 41 | } 42 | if pfx != pfx.Masked() { 43 | return nil, fmt.Errorf("%v contains non-network bits set", pfx) 44 | } 45 | 46 | ipSet.AddPrefix(pfx) 47 | 48 | return ipSet.IPSet() 49 | } 50 | if strings.Count(arg, "-") == 1 { 51 | ip1s, ip2s, _ := strings.Cut(arg, "-") 52 | 53 | ip1, err := netip.ParseAddr(ip1s) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | ip2, err := netip.ParseAddr(ip2s) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | r := netipx.IPRangeFrom(ip1, ip2) 64 | if !r.IsValid() { 65 | return nil, fmt.Errorf("invalid IP range %q", arg) 66 | } 67 | 68 | for _, prefix := range r.Prefixes() { 69 | ipSet.AddPrefix(prefix) 70 | } 71 | 72 | return ipSet.IPSet() 73 | } 74 | ip, err := netip.ParseAddr(arg) 75 | if err != nil { 76 | return nil, fmt.Errorf("invalid IP address %q", arg) 77 | } 78 | bits8 := uint8(ip.BitLen()) 79 | if bits != nil { 80 | if *bits < 0 || *bits > int(bits8) { 81 | return nil, fmt.Errorf("invalid CIDR size %d for IP %q", *bits, arg) 82 | } 83 | bits8 = uint8(*bits) 84 | } 85 | 86 | ipSet.AddPrefix(netip.PrefixFrom(ip, int(bits8))) 87 | 88 | return ipSet.IPSet() 89 | } 90 | 91 | func GetIPPrefixEndpoints(na netip.Prefix) (netip.Addr, netip.Addr) { 92 | var network, broadcast netip.Addr 93 | ipRange := netipx.RangeOfPrefix(na) 94 | network = ipRange.From() 95 | broadcast = ipRange.To() 96 | 97 | return network, broadcast 98 | } 99 | 100 | func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) { 101 | result := make([]netip.Prefix, len(prefixes)) 102 | 103 | for index, prefixStr := range prefixes { 104 | prefix, err := netip.ParsePrefix(prefixStr) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | result[index] = prefix 110 | } 111 | 112 | return result, nil 113 | } 114 | -------------------------------------------------------------------------------- /hscontrol/db/text_serialiser.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "encoding" 6 | "fmt" 7 | "reflect" 8 | 9 | "gorm.io/gorm/schema" 10 | ) 11 | 12 | // Got from https://github.com/xdg-go/strum/blob/main/types.go 13 | var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() 14 | 15 | func isTextUnmarshaler(rv reflect.Value) bool { 16 | return rv.Type().Implements(textUnmarshalerType) 17 | } 18 | 19 | func maybeInstantiatePtr(rv reflect.Value) { 20 | if rv.Kind() == reflect.Ptr && rv.IsNil() { 21 | np := reflect.New(rv.Type().Elem()) 22 | rv.Set(np) 23 | } 24 | } 25 | 26 | func decodingError(name string, err error) error { 27 | return fmt.Errorf("error decoding to %s: %w", name, err) 28 | } 29 | 30 | // TextSerialiser implements the Serialiser interface for fields that 31 | // have a type that implements encoding.TextUnmarshaler. 32 | type TextSerialiser struct{} 33 | 34 | func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue interface{}) (err error) { 35 | fieldValue := reflect.New(field.FieldType) 36 | 37 | // If the field is a pointer, we need to dereference it to get the actual type 38 | // so we do not end with a second pointer. 39 | if fieldValue.Elem().Kind() == reflect.Ptr { 40 | fieldValue = fieldValue.Elem() 41 | } 42 | 43 | if dbValue != nil { 44 | var bytes []byte 45 | switch v := dbValue.(type) { 46 | case []byte: 47 | bytes = v 48 | case string: 49 | bytes = []byte(v) 50 | default: 51 | return fmt.Errorf("failed to unmarshal text value: %#v", dbValue) 52 | } 53 | 54 | if isTextUnmarshaler(fieldValue) { 55 | maybeInstantiatePtr(fieldValue) 56 | f := fieldValue.MethodByName("UnmarshalText") 57 | args := []reflect.Value{reflect.ValueOf(bytes)} 58 | ret := f.Call(args) 59 | if !ret[0].IsNil() { 60 | return decodingError(field.Name, ret[0].Interface().(error)) 61 | } 62 | 63 | // If the underlying field is to a pointer type, we need to 64 | // assign the value as a pointer to it. 65 | // If it is not a pointer, we need to assign the value to the 66 | // field. 67 | dstField := field.ReflectValueOf(ctx, dst) 68 | if dstField.Kind() == reflect.Ptr { 69 | dstField.Set(fieldValue) 70 | } else { 71 | dstField.Set(fieldValue.Elem()) 72 | } 73 | return nil 74 | } else { 75 | return fmt.Errorf("unsupported type: %T", fieldValue.Interface()) 76 | } 77 | } 78 | 79 | return 80 | } 81 | 82 | func (TextSerialiser) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue interface{}) (interface{}, error) { 83 | switch v := fieldValue.(type) { 84 | case encoding.TextMarshaler: 85 | // If the value is nil, we return nil, however, go nil values are not 86 | // always comparable, particularly when reflection is involved: 87 | // https://dev.to/arxeiss/in-go-nil-is-not-equal-to-nil-sometimes-jn8 88 | if v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil()) { 89 | return nil, nil 90 | } 91 | b, err := v.MarshalText() 92 | if err != nil { 93 | return nil, err 94 | } 95 | return string(b), nil 96 | default: 97 | return nil, fmt.Errorf("only encoding.TextMarshaler is supported, got %t", v) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /hscontrol/auth_test.go: -------------------------------------------------------------------------------- 1 | package hscontrol 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/juanfont/headscale/hscontrol/types" 10 | ) 11 | 12 | func TestCanUsePreAuthKey(t *testing.T) { 13 | now := time.Now() 14 | past := now.Add(-time.Hour) 15 | future := now.Add(time.Hour) 16 | 17 | tests := []struct { 18 | name string 19 | pak *types.PreAuthKey 20 | wantErr bool 21 | err HTTPError 22 | }{ 23 | { 24 | name: "valid reusable key", 25 | pak: &types.PreAuthKey{ 26 | Reusable: true, 27 | Used: false, 28 | Expiration: &future, 29 | }, 30 | wantErr: false, 31 | }, 32 | { 33 | name: "valid non-reusable key", 34 | pak: &types.PreAuthKey{ 35 | Reusable: false, 36 | Used: false, 37 | Expiration: &future, 38 | }, 39 | wantErr: false, 40 | }, 41 | { 42 | name: "expired key", 43 | pak: &types.PreAuthKey{ 44 | Reusable: false, 45 | Used: false, 46 | Expiration: &past, 47 | }, 48 | wantErr: true, 49 | err: NewHTTPError(http.StatusUnauthorized, "authkey expired", nil), 50 | }, 51 | { 52 | name: "used non-reusable key", 53 | pak: &types.PreAuthKey{ 54 | Reusable: false, 55 | Used: true, 56 | Expiration: &future, 57 | }, 58 | wantErr: true, 59 | err: NewHTTPError(http.StatusUnauthorized, "authkey already used", nil), 60 | }, 61 | { 62 | name: "used reusable key", 63 | pak: &types.PreAuthKey{ 64 | Reusable: true, 65 | Used: true, 66 | Expiration: &future, 67 | }, 68 | wantErr: false, 69 | }, 70 | { 71 | name: "no expiration date", 72 | pak: &types.PreAuthKey{ 73 | Reusable: false, 74 | Used: false, 75 | Expiration: nil, 76 | }, 77 | wantErr: false, 78 | }, 79 | { 80 | name: "nil preauth key", 81 | pak: nil, 82 | wantErr: true, 83 | err: NewHTTPError(http.StatusUnauthorized, "invalid authkey", nil), 84 | }, 85 | { 86 | name: "expired and used key", 87 | pak: &types.PreAuthKey{ 88 | Reusable: false, 89 | Used: true, 90 | Expiration: &past, 91 | }, 92 | wantErr: true, 93 | err: NewHTTPError(http.StatusUnauthorized, "authkey expired", nil), 94 | }, 95 | { 96 | name: "no expiration and used key", 97 | pak: &types.PreAuthKey{ 98 | Reusable: false, 99 | Used: true, 100 | Expiration: nil, 101 | }, 102 | wantErr: true, 103 | err: NewHTTPError(http.StatusUnauthorized, "authkey already used", nil), 104 | }, 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | err := canUsePreAuthKey(tt.pak) 110 | if tt.wantErr { 111 | if err == nil { 112 | t.Errorf("expected error but got none") 113 | } else { 114 | httpErr, ok := err.(HTTPError) 115 | if !ok { 116 | t.Errorf("expected HTTPError but got %T", err) 117 | } else { 118 | if diff := cmp.Diff(tt.err, httpErr); diff != "" { 119 | t.Errorf("unexpected error (-want +got):\n%s", diff) 120 | } 121 | } 122 | } 123 | } else { 124 | if err != nil { 125 | t.Errorf("expected no error but got %v", err) 126 | } 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build-nix: 17 | runs-on: ubuntu-latest 18 | permissions: write-all 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 2 23 | - name: Get changed files 24 | id: changed-files 25 | uses: dorny/paths-filter@v3 26 | with: 27 | filters: | 28 | files: 29 | - '*.nix' 30 | - 'go.*' 31 | - '**/*.go' 32 | - 'integration_test/' 33 | - 'config-example.yaml' 34 | - uses: DeterminateSystems/nix-installer-action@main 35 | if: steps.changed-files.outputs.files == 'true' 36 | - uses: DeterminateSystems/magic-nix-cache-action@main 37 | if: steps.changed-files.outputs.files == 'true' 38 | 39 | - name: Run nix build 40 | id: build 41 | if: steps.changed-files.outputs.files == 'true' 42 | run: | 43 | nix build |& tee build-result 44 | BUILD_STATUS="${PIPESTATUS[0]}" 45 | 46 | OLD_HASH=$(cat build-result | grep specified: | awk -F ':' '{print $2}' | sed 's/ //g') 47 | NEW_HASH=$(cat build-result | grep got: | awk -F ':' '{print $2}' | sed 's/ //g') 48 | 49 | echo "OLD_HASH=$OLD_HASH" >> $GITHUB_OUTPUT 50 | echo "NEW_HASH=$NEW_HASH" >> $GITHUB_OUTPUT 51 | 52 | exit $BUILD_STATUS 53 | 54 | - name: Nix gosum diverging 55 | uses: actions/github-script@v6 56 | if: failure() && steps.build.outcome == 'failure' 57 | with: 58 | github-token: ${{secrets.GITHUB_TOKEN}} 59 | script: | 60 | github.rest.pulls.createReviewComment({ 61 | pull_number: context.issue.number, 62 | owner: context.repo.owner, 63 | repo: context.repo.repo, 64 | body: 'Nix build failed with wrong gosum, please update "vendorSha256" (${{ steps.build.outputs.OLD_HASH }}) for the "headscale" package in flake.nix with the new SHA: ${{ steps.build.outputs.NEW_HASH }}' 65 | }) 66 | 67 | - uses: actions/upload-artifact@v4 68 | if: steps.changed-files.outputs.files == 'true' 69 | with: 70 | name: headscale-linux 71 | path: result/bin/headscale 72 | build-cross: 73 | runs-on: ubuntu-latest 74 | strategy: 75 | matrix: 76 | env: 77 | - "GOARCH=arm GOOS=linux GOARM=5" 78 | - "GOARCH=arm GOOS=linux GOARM=6" 79 | - "GOARCH=arm GOOS=linux GOARM=7" 80 | - "GOARCH=arm64 GOOS=linux" 81 | - "GOARCH=386 GOOS=linux" 82 | - "GOARCH=amd64 GOOS=linux" 83 | - "GOARCH=arm64 GOOS=darwin" 84 | - "GOARCH=amd64 GOOS=darwin" 85 | steps: 86 | - uses: actions/checkout@v4 87 | - uses: DeterminateSystems/nix-installer-action@main 88 | - uses: DeterminateSystems/magic-nix-cache-action@main 89 | 90 | - name: Run go cross compile 91 | run: env ${{ matrix.env }} nix develop --command -- go build -o "headscale" ./cmd/headscale 92 | - uses: actions/upload-artifact@v4 93 | with: 94 | name: "headscale-${{ matrix.env }}" 95 | path: "headscale" 96 | -------------------------------------------------------------------------------- /hscontrol/derp/derp.go: -------------------------------------------------------------------------------- 1 | package derp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | 11 | "github.com/juanfont/headscale/hscontrol/types" 12 | "github.com/rs/zerolog/log" 13 | "gopkg.in/yaml.v3" 14 | "tailscale.com/tailcfg" 15 | ) 16 | 17 | func loadDERPMapFromPath(path string) (*tailcfg.DERPMap, error) { 18 | derpFile, err := os.Open(path) 19 | if err != nil { 20 | return nil, err 21 | } 22 | defer derpFile.Close() 23 | var derpMap tailcfg.DERPMap 24 | b, err := io.ReadAll(derpFile) 25 | if err != nil { 26 | return nil, err 27 | } 28 | err = yaml.Unmarshal(b, &derpMap) 29 | 30 | return &derpMap, err 31 | } 32 | 33 | func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) { 34 | ctx, cancel := context.WithTimeout(context.Background(), types.HTTPTimeout) 35 | defer cancel() 36 | 37 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr.String(), nil) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | client := http.Client{ 43 | Timeout: types.HTTPTimeout, 44 | } 45 | 46 | resp, err := client.Do(req) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | defer resp.Body.Close() 52 | body, err := io.ReadAll(resp.Body) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | var derpMap tailcfg.DERPMap 58 | err = json.Unmarshal(body, &derpMap) 59 | 60 | return &derpMap, err 61 | } 62 | 63 | // mergeDERPMaps naively merges a list of DERPMaps into a single 64 | // DERPMap, it will _only_ look at the Regions, an integer. 65 | // If a region exists in two of the given DERPMaps, the region 66 | // form the _last_ DERPMap will be preserved. 67 | // An empty DERPMap list will result in a DERPMap with no regions. 68 | func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap { 69 | result := tailcfg.DERPMap{ 70 | OmitDefaultRegions: false, 71 | Regions: map[int]*tailcfg.DERPRegion{}, 72 | } 73 | 74 | for _, derpMap := range derpMaps { 75 | for id, region := range derpMap.Regions { 76 | result.Regions[id] = region 77 | } 78 | } 79 | 80 | return &result 81 | } 82 | 83 | func GetDERPMap(cfg types.DERPConfig) *tailcfg.DERPMap { 84 | var derpMaps []*tailcfg.DERPMap 85 | if cfg.DERPMap != nil { 86 | derpMaps = append(derpMaps, cfg.DERPMap) 87 | } 88 | 89 | for _, path := range cfg.Paths { 90 | log.Debug(). 91 | Str("func", "GetDERPMap"). 92 | Str("path", path). 93 | Msg("Loading DERPMap from path") 94 | derpMap, err := loadDERPMapFromPath(path) 95 | if err != nil { 96 | log.Error(). 97 | Str("func", "GetDERPMap"). 98 | Str("path", path). 99 | Err(err). 100 | Msg("Could not load DERP map from path") 101 | 102 | break 103 | } 104 | 105 | derpMaps = append(derpMaps, derpMap) 106 | } 107 | 108 | for _, addr := range cfg.URLs { 109 | derpMap, err := loadDERPMapFromURL(addr) 110 | log.Debug(). 111 | Str("func", "GetDERPMap"). 112 | Str("url", addr.String()). 113 | Msg("Loading DERPMap from path") 114 | if err != nil { 115 | log.Error(). 116 | Str("func", "GetDERPMap"). 117 | Str("url", addr.String()). 118 | Err(err). 119 | Msg("Could not load DERP map from path") 120 | 121 | break 122 | } 123 | 124 | derpMaps = append(derpMaps, derpMap) 125 | } 126 | 127 | derpMap := mergeDERPMaps(derpMaps) 128 | 129 | log.Trace().Interface("derpMap", derpMap).Msg("DERPMap loaded") 130 | 131 | return derpMap 132 | } 133 | -------------------------------------------------------------------------------- /hscontrol/db/api_key.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/juanfont/headscale/hscontrol/types" 10 | "github.com/juanfont/headscale/hscontrol/util" 11 | "golang.org/x/crypto/bcrypt" 12 | ) 13 | 14 | const ( 15 | apiPrefixLength = 7 16 | apiKeyLength = 32 17 | ) 18 | 19 | var ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey") 20 | 21 | // CreateAPIKey creates a new ApiKey in a user, and returns it. 22 | func (hsdb *HSDatabase) CreateAPIKey( 23 | expiration *time.Time, 24 | ) (string, *types.APIKey, error) { 25 | prefix, err := util.GenerateRandomStringURLSafe(apiPrefixLength) 26 | if err != nil { 27 | return "", nil, err 28 | } 29 | 30 | toBeHashed, err := util.GenerateRandomStringURLSafe(apiKeyLength) 31 | if err != nil { 32 | return "", nil, err 33 | } 34 | 35 | // Key to return to user, this will only be visible _once_ 36 | keyStr := prefix + "." + toBeHashed 37 | 38 | hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost) 39 | if err != nil { 40 | return "", nil, err 41 | } 42 | 43 | key := types.APIKey{ 44 | Prefix: prefix, 45 | Hash: hash, 46 | Expiration: expiration, 47 | } 48 | 49 | if err := hsdb.DB.Save(&key).Error; err != nil { 50 | return "", nil, fmt.Errorf("failed to save API key to database: %w", err) 51 | } 52 | 53 | return keyStr, &key, nil 54 | } 55 | 56 | // ListAPIKeys returns the list of ApiKeys for a user. 57 | func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) { 58 | keys := []types.APIKey{} 59 | if err := hsdb.DB.Find(&keys).Error; err != nil { 60 | return nil, err 61 | } 62 | 63 | return keys, nil 64 | } 65 | 66 | // GetAPIKey returns a ApiKey for a given key. 67 | func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) { 68 | key := types.APIKey{} 69 | if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil { 70 | return nil, result.Error 71 | } 72 | 73 | return &key, nil 74 | } 75 | 76 | // GetAPIKeyByID returns a ApiKey for a given id. 77 | func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) { 78 | key := types.APIKey{} 79 | if result := hsdb.DB.Find(&types.APIKey{ID: id}).First(&key); result.Error != nil { 80 | return nil, result.Error 81 | } 82 | 83 | return &key, nil 84 | } 85 | 86 | // DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey 87 | // does not exist. 88 | func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error { 89 | if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil { 90 | return result.Error 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // ExpireAPIKey marks a ApiKey as expired. 97 | func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error { 98 | if err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error; err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) { 106 | prefix, hash, found := strings.Cut(keyStr, ".") 107 | if !found { 108 | return false, ErrAPIKeyFailedToParse 109 | } 110 | 111 | key, err := hsdb.GetAPIKey(prefix) 112 | if err != nil { 113 | return false, fmt.Errorf("failed to validate api key: %w", err) 114 | } 115 | 116 | if key.Expiration.Before(time.Now()) { 117 | return false, nil 118 | } 119 | 120 | if err := bcrypt.CompareHashAndPassword(key.Hash, []byte(hash)); err != nil { 121 | return false, err 122 | } 123 | 124 | return true, nil 125 | } 126 | -------------------------------------------------------------------------------- /cmd/headscale/cli/debug.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1" 7 | "github.com/juanfont/headscale/hscontrol/types" 8 | "github.com/rs/zerolog/log" 9 | "github.com/spf13/cobra" 10 | "google.golang.org/grpc/status" 11 | ) 12 | 13 | const ( 14 | errPreAuthKeyMalformed = Error("key is malformed. expected 64 hex characters with `nodekey` prefix") 15 | ) 16 | 17 | // Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors 18 | type Error string 19 | 20 | func (e Error) Error() string { return string(e) } 21 | 22 | func init() { 23 | rootCmd.AddCommand(debugCmd) 24 | 25 | createNodeCmd.Flags().StringP("name", "", "", "Name") 26 | err := createNodeCmd.MarkFlagRequired("name") 27 | if err != nil { 28 | log.Fatal().Err(err).Msg("") 29 | } 30 | createNodeCmd.Flags().StringP("user", "u", "", "User") 31 | 32 | createNodeCmd.Flags().StringP("namespace", "n", "", "User") 33 | createNodeNamespaceFlag := createNodeCmd.Flags().Lookup("namespace") 34 | createNodeNamespaceFlag.Deprecated = deprecateNamespaceMessage 35 | createNodeNamespaceFlag.Hidden = true 36 | 37 | err = createNodeCmd.MarkFlagRequired("user") 38 | if err != nil { 39 | log.Fatal().Err(err).Msg("") 40 | } 41 | createNodeCmd.Flags().StringP("key", "k", "", "Key") 42 | err = createNodeCmd.MarkFlagRequired("key") 43 | if err != nil { 44 | log.Fatal().Err(err).Msg("") 45 | } 46 | createNodeCmd.Flags(). 47 | StringSliceP("route", "r", []string{}, "List (or repeated flags) of routes to advertise") 48 | 49 | debugCmd.AddCommand(createNodeCmd) 50 | } 51 | 52 | var debugCmd = &cobra.Command{ 53 | Use: "debug", 54 | Short: "debug and testing commands", 55 | Long: "debug contains extra commands used for debugging and testing headscale", 56 | } 57 | 58 | var createNodeCmd = &cobra.Command{ 59 | Use: "create-node", 60 | Short: "Create a node that can be registered with `nodes register <>` command", 61 | Run: func(cmd *cobra.Command, args []string) { 62 | output, _ := cmd.Flags().GetString("output") 63 | 64 | user, err := cmd.Flags().GetString("user") 65 | if err != nil { 66 | ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output) 67 | } 68 | 69 | ctx, client, conn, cancel := newHeadscaleCLIWithConfig() 70 | defer cancel() 71 | defer conn.Close() 72 | 73 | name, err := cmd.Flags().GetString("name") 74 | if err != nil { 75 | ErrorOutput( 76 | err, 77 | fmt.Sprintf("Error getting node from flag: %s", err), 78 | output, 79 | ) 80 | } 81 | 82 | registrationID, err := cmd.Flags().GetString("key") 83 | if err != nil { 84 | ErrorOutput( 85 | err, 86 | fmt.Sprintf("Error getting key from flag: %s", err), 87 | output, 88 | ) 89 | } 90 | 91 | _, err = types.RegistrationIDFromString(registrationID) 92 | if err != nil { 93 | ErrorOutput( 94 | err, 95 | fmt.Sprintf("Failed to parse machine key from flag: %s", err), 96 | output, 97 | ) 98 | } 99 | 100 | routes, err := cmd.Flags().GetStringSlice("route") 101 | if err != nil { 102 | ErrorOutput( 103 | err, 104 | fmt.Sprintf("Error getting routes from flag: %s", err), 105 | output, 106 | ) 107 | } 108 | 109 | request := &v1.DebugCreateNodeRequest{ 110 | Key: registrationID, 111 | Name: name, 112 | User: user, 113 | Routes: routes, 114 | } 115 | 116 | response, err := client.DebugCreateNode(ctx, request) 117 | if err != nil { 118 | ErrorOutput( 119 | err, 120 | fmt.Sprintf("Cannot create node: %s", status.Convert(err).Message()), 121 | output, 122 | ) 123 | } 124 | 125 | SuccessOutput(response.GetNode(), "Node created", output) 126 | }, 127 | } 128 | -------------------------------------------------------------------------------- /docs/about/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## What is the design goal of headscale? 4 | 5 | Headscale aims to implement a self-hosted, open source alternative to the 6 | [Tailscale](https://tailscale.com/) control server. Headscale's goal is to 7 | provide self-hosters and hobbyists with an open-source server they can use for 8 | their projects and labs. It implements a narrow scope, a _single_ Tailscale 9 | network (tailnet), suitable for a personal use, or a small open-source 10 | organisation. 11 | 12 | ## How can I contribute? 13 | 14 | Headscale is "Open Source, acknowledged contribution", this means that any 15 | contribution will have to be discussed with the Maintainers before being submitted. 16 | 17 | Please see [Contributing](contributing.md) for more information. 18 | 19 | ## Why is 'acknowledged contribution' the chosen model? 20 | 21 | 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. 22 | 23 | We are more than happy to exchange emails, or to have dedicated calls before a PR is submitted. 24 | 25 | ## When/Why is Feature X going to be implemented? 26 | 27 | We don't know. We might be working on it. If you're interested in contributing, please post a feature request about it. 28 | 29 | Please be aware that there are a number of reasons why we might not accept specific contributions: 30 | 31 | - It is not possible to implement the feature in a way that makes sense in a self-hosted environment. 32 | - Given that we are reverse-engineering Tailscale to satisfy our own curiosity, we might be interested in implementing the feature ourselves. 33 | - You are not sending unit and integration tests with it. 34 | 35 | ## Do you support Y method of deploying headscale? 36 | 37 | We currently support deploying headscale using our binaries and the DEB packages. Visit our [installation guide using 38 | official releases](../setup/install/official.md) for more information. 39 | 40 | In addition to that, you may use packages provided by the community or from distributions. Learn more in the 41 | [installation guide using community packages](../setup/install/community.md). 42 | 43 | For convenience, we also [build Docker images with headscale](../setup/install/container.md). But **please be aware that 44 | we don't officially support deploying headscale using Docker**. On our [Discord server](https://discord.gg/c84AZQhmpx) 45 | we have a "docker-issues" channel where you can ask for Docker-specific help to the community. 46 | 47 | ## Which database should I use? 48 | 49 | We recommend the use of SQLite as database for headscale: 50 | 51 | - SQLite is simple to setup and easy to use 52 | - It scales well for all of headscale's usecases 53 | - Development and testing happens primarily on SQLite 54 | - PostgreSQL is still supported, but is considered to be in "maintenance mode" 55 | 56 | The headscale project itself does not provide a tool to migrate from PostgreSQL to SQLite. Please have a look at [the 57 | related tools documentation](../ref/integration/tools.md) for migration tooling provided by the community. 58 | 59 | ## Why is my reverse proxy not working with headscale? 60 | 61 | We don't know. We don't use reverse proxies with headscale ourselves, so we don't have any experience with them. We have 62 | [community documentation](../ref/integration/reverse-proxy.md) on how to configure various reverse proxies, and a 63 | dedicated "reverse-proxy-issues" channel on our [Discord server](https://discord.gg/c84AZQhmpx) where you can ask for 64 | help to the community. 65 | 66 | ## Can I use headscale and tailscale on the same machine? 67 | 68 | Running headscale on a machine that is also in the tailnet can cause problems with subnet routers, traffic relay nodes, and MagicDNS. It might work, but it is not supported. 69 | -------------------------------------------------------------------------------- /hscontrol/policy/pm_test.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/juanfont/headscale/hscontrol/types" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "gorm.io/gorm" 11 | "tailscale.com/tailcfg" 12 | ) 13 | 14 | func TestPolicySetChange(t *testing.T) { 15 | users := []types.User{ 16 | { 17 | Model: gorm.Model{ID: 1}, 18 | Name: "testuser", 19 | }, 20 | } 21 | tests := []struct { 22 | name string 23 | users []types.User 24 | nodes types.Nodes 25 | policy []byte 26 | wantUsersChange bool 27 | wantNodesChange bool 28 | wantPolicyChange bool 29 | wantFilter []tailcfg.FilterRule 30 | }{ 31 | { 32 | name: "set-nodes", 33 | nodes: types.Nodes{ 34 | { 35 | IPv4: iap("100.64.0.2"), 36 | User: users[0], 37 | }, 38 | }, 39 | wantNodesChange: false, 40 | wantFilter: []tailcfg.FilterRule{ 41 | { 42 | DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}}, 43 | }, 44 | }, 45 | }, 46 | { 47 | name: "set-users", 48 | users: users, 49 | wantUsersChange: false, 50 | wantFilter: []tailcfg.FilterRule{ 51 | { 52 | DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}}, 53 | }, 54 | }, 55 | }, 56 | { 57 | name: "set-users-and-node", 58 | users: users, 59 | nodes: types.Nodes{ 60 | { 61 | IPv4: iap("100.64.0.2"), 62 | User: users[0], 63 | }, 64 | }, 65 | wantUsersChange: false, 66 | wantNodesChange: true, 67 | wantFilter: []tailcfg.FilterRule{ 68 | { 69 | SrcIPs: []string{"100.64.0.2/32"}, 70 | DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}}, 71 | }, 72 | }, 73 | }, 74 | { 75 | name: "set-policy", 76 | policy: []byte(` 77 | { 78 | "acls": [ 79 | { 80 | "action": "accept", 81 | "src": [ 82 | "100.64.0.61", 83 | ], 84 | "dst": [ 85 | "100.64.0.62:*", 86 | ], 87 | }, 88 | ], 89 | } 90 | `), 91 | wantPolicyChange: true, 92 | wantFilter: []tailcfg.FilterRule{ 93 | { 94 | SrcIPs: []string{"100.64.0.61/32"}, 95 | DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.62/32", Ports: tailcfg.PortRangeAny}}, 96 | }, 97 | }, 98 | }, 99 | } 100 | 101 | for _, tt := range tests { 102 | t.Run(tt.name, func(t *testing.T) { 103 | pol := ` 104 | { 105 | "groups": { 106 | "group:example": [ 107 | "testuser", 108 | ], 109 | }, 110 | 111 | "hosts": { 112 | "host-1": "100.64.0.1", 113 | "subnet-1": "100.100.101.100/24", 114 | }, 115 | 116 | "acls": [ 117 | { 118 | "action": "accept", 119 | "src": [ 120 | "group:example", 121 | ], 122 | "dst": [ 123 | "host-1:*", 124 | ], 125 | }, 126 | ], 127 | } 128 | ` 129 | pm, err := NewPolicyManager([]byte(pol), []types.User{}, types.Nodes{}) 130 | require.NoError(t, err) 131 | 132 | if tt.policy != nil { 133 | change, err := pm.SetPolicy(tt.policy) 134 | require.NoError(t, err) 135 | 136 | assert.Equal(t, tt.wantPolicyChange, change) 137 | } 138 | 139 | if tt.users != nil { 140 | change, err := pm.SetUsers(tt.users) 141 | require.NoError(t, err) 142 | 143 | assert.Equal(t, tt.wantUsersChange, change) 144 | } 145 | 146 | if tt.nodes != nil { 147 | change, err := pm.SetNodes(tt.nodes) 148 | require.NoError(t, err) 149 | 150 | assert.Equal(t, tt.wantNodesChange, change) 151 | } 152 | 153 | if diff := cmp.Diff(tt.wantFilter, pm.Filter()); diff != "" { 154 | t.Errorf("TestPolicySetChange() unexpected result (-want +got):\n%s", diff) 155 | } 156 | }) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /cmd/headscale/headscale_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/juanfont/headscale/hscontrol/types" 10 | "github.com/juanfont/headscale/hscontrol/util" 11 | "github.com/spf13/viper" 12 | "gopkg.in/check.v1" 13 | ) 14 | 15 | func Test(t *testing.T) { 16 | check.TestingT(t) 17 | } 18 | 19 | var _ = check.Suite(&Suite{}) 20 | 21 | type Suite struct{} 22 | 23 | func (s *Suite) SetUpSuite(c *check.C) { 24 | } 25 | 26 | func (s *Suite) TearDownSuite(c *check.C) { 27 | } 28 | 29 | func (*Suite) TestConfigFileLoading(c *check.C) { 30 | tmpDir, err := os.MkdirTemp("", "headscale") 31 | if err != nil { 32 | c.Fatal(err) 33 | } 34 | defer os.RemoveAll(tmpDir) 35 | 36 | path, err := os.Getwd() 37 | if err != nil { 38 | c.Fatal(err) 39 | } 40 | 41 | cfgFile := filepath.Join(tmpDir, "config.yaml") 42 | 43 | // Symlink the example config file 44 | err = os.Symlink( 45 | filepath.Clean(path+"/../../config-example.yaml"), 46 | cfgFile, 47 | ) 48 | if err != nil { 49 | c.Fatal(err) 50 | } 51 | 52 | // Load example config, it should load without validation errors 53 | err = types.LoadConfig(cfgFile, true) 54 | c.Assert(err, check.IsNil) 55 | 56 | // Test that config file was interpreted correctly 57 | c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080") 58 | c.Assert(viper.GetString("listen_addr"), check.Equals, "127.0.0.1:8080") 59 | c.Assert(viper.GetString("metrics_listen_addr"), check.Equals, "127.0.0.1:9090") 60 | c.Assert(viper.GetString("database.type"), check.Equals, "sqlite") 61 | c.Assert(viper.GetString("database.sqlite.path"), check.Equals, "/var/lib/headscale/db.sqlite") 62 | c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") 63 | c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http") 64 | c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01") 65 | c.Assert( 66 | util.GetFileMode("unix_socket_permission"), 67 | check.Equals, 68 | fs.FileMode(0o770), 69 | ) 70 | c.Assert(viper.GetBool("logtail.enabled"), check.Equals, false) 71 | } 72 | 73 | func (*Suite) TestConfigLoading(c *check.C) { 74 | tmpDir, err := os.MkdirTemp("", "headscale") 75 | if err != nil { 76 | c.Fatal(err) 77 | } 78 | defer os.RemoveAll(tmpDir) 79 | 80 | path, err := os.Getwd() 81 | if err != nil { 82 | c.Fatal(err) 83 | } 84 | 85 | // Symlink the example config file 86 | err = os.Symlink( 87 | filepath.Clean(path+"/../../config-example.yaml"), 88 | filepath.Join(tmpDir, "config.yaml"), 89 | ) 90 | if err != nil { 91 | c.Fatal(err) 92 | } 93 | 94 | // Load example config, it should load without validation errors 95 | err = types.LoadConfig(tmpDir, false) 96 | c.Assert(err, check.IsNil) 97 | 98 | // Test that config file was interpreted correctly 99 | c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080") 100 | c.Assert(viper.GetString("listen_addr"), check.Equals, "127.0.0.1:8080") 101 | c.Assert(viper.GetString("metrics_listen_addr"), check.Equals, "127.0.0.1:9090") 102 | c.Assert(viper.GetString("database.type"), check.Equals, "sqlite") 103 | c.Assert(viper.GetString("database.sqlite.path"), check.Equals, "/var/lib/headscale/db.sqlite") 104 | c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") 105 | c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http") 106 | c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01") 107 | c.Assert( 108 | util.GetFileMode("unix_socket_permission"), 109 | check.Equals, 110 | fs.FileMode(0o770), 111 | ) 112 | c.Assert(viper.GetBool("logtail.enabled"), check.Equals, false) 113 | c.Assert(viper.GetBool("randomize_client_port"), check.Equals, false) 114 | } 115 | -------------------------------------------------------------------------------- /hscontrol/policy/acls_types.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "encoding/json" 5 | "net/netip" 6 | "strings" 7 | 8 | "github.com/tailscale/hujson" 9 | ) 10 | 11 | // ACLPolicy represents a Tailscale ACL Policy. 12 | type ACLPolicy struct { 13 | Groups Groups `json:"groups"` 14 | Hosts Hosts `json:"hosts"` 15 | TagOwners TagOwners `json:"tagOwners"` 16 | ACLs []ACL `json:"acls"` 17 | Tests []ACLTest `json:"tests"` 18 | AutoApprovers AutoApprovers `json:"autoApprovers"` 19 | SSHs []SSH `json:"ssh"` 20 | } 21 | 22 | // ACL is a basic rule for the ACL Policy. 23 | type ACL struct { 24 | Action string `json:"action"` 25 | Protocol string `json:"proto"` 26 | Sources []string `json:"src"` 27 | Destinations []string `json:"dst"` 28 | } 29 | 30 | // Groups references a series of alias in the ACL rules. 31 | type Groups map[string][]string 32 | 33 | // Hosts are alias for IP addresses or subnets. 34 | type Hosts map[string]netip.Prefix 35 | 36 | // TagOwners specify what users (users?) are allow to use certain tags. 37 | type TagOwners map[string][]string 38 | 39 | // ACLTest is not implemented, but should be used to check if a certain rule is allowed. 40 | type ACLTest struct { 41 | Source string `json:"src"` 42 | Accept []string `json:"accept"` 43 | Deny []string `json:"deny,omitempty"` 44 | } 45 | 46 | // AutoApprovers specify which users (users?), groups or tags have their advertised routes 47 | // or exit node status automatically enabled. 48 | type AutoApprovers struct { 49 | Routes map[string][]string `json:"routes"` 50 | ExitNode []string `json:"exitNode"` 51 | } 52 | 53 | // SSH controls who can ssh into which machines. 54 | type SSH struct { 55 | Action string `json:"action"` 56 | Sources []string `json:"src"` 57 | Destinations []string `json:"dst"` 58 | Users []string `json:"users"` 59 | CheckPeriod string `json:"checkPeriod,omitempty"` 60 | } 61 | 62 | // UnmarshalJSON allows to parse the Hosts directly into netip objects. 63 | func (hosts *Hosts) UnmarshalJSON(data []byte) error { 64 | newHosts := Hosts{} 65 | hostIPPrefixMap := make(map[string]string) 66 | ast, err := hujson.Parse(data) 67 | if err != nil { 68 | return err 69 | } 70 | ast.Standardize() 71 | data = ast.Pack() 72 | err = json.Unmarshal(data, &hostIPPrefixMap) 73 | if err != nil { 74 | return err 75 | } 76 | for host, prefixStr := range hostIPPrefixMap { 77 | if !strings.Contains(prefixStr, "/") { 78 | prefixStr += "/32" 79 | } 80 | prefix, err := netip.ParsePrefix(prefixStr) 81 | if err != nil { 82 | return err 83 | } 84 | newHosts[host] = prefix 85 | } 86 | *hosts = newHosts 87 | 88 | return nil 89 | } 90 | 91 | // IsZero is perhaps a bit naive here. 92 | func (pol ACLPolicy) IsZero() bool { 93 | if len(pol.Groups) == 0 && len(pol.Hosts) == 0 && len(pol.ACLs) == 0 { 94 | return true 95 | } 96 | 97 | return false 98 | } 99 | 100 | // GetRouteApprovers returns the list of autoApproving users, groups or tags for a given IPPrefix. 101 | func (autoApprovers *AutoApprovers) GetRouteApprovers( 102 | prefix netip.Prefix, 103 | ) ([]string, error) { 104 | if prefix.Bits() == 0 { 105 | return autoApprovers.ExitNode, nil // 0.0.0.0/0, ::/0 or equivalent 106 | } 107 | 108 | approverAliases := make([]string, 0) 109 | 110 | for autoApprovedPrefix, autoApproverAliases := range autoApprovers.Routes { 111 | autoApprovedPrefix, err := netip.ParsePrefix(autoApprovedPrefix) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | if prefix.Bits() >= autoApprovedPrefix.Bits() && 117 | autoApprovedPrefix.Contains(prefix.Masked().Addr()) { 118 | approverAliases = append(approverAliases, autoApproverAliases...) 119 | } 120 | } 121 | 122 | return approverAliases, nil 123 | } 124 | -------------------------------------------------------------------------------- /hscontrol/mapper/tail.go: -------------------------------------------------------------------------------- 1 | package mapper 2 | 3 | import ( 4 | "fmt" 5 | "net/netip" 6 | "time" 7 | 8 | "github.com/juanfont/headscale/hscontrol/policy" 9 | "github.com/juanfont/headscale/hscontrol/types" 10 | "github.com/samber/lo" 11 | "tailscale.com/tailcfg" 12 | ) 13 | 14 | func tailNodes( 15 | nodes types.Nodes, 16 | capVer tailcfg.CapabilityVersion, 17 | polMan policy.PolicyManager, 18 | cfg *types.Config, 19 | ) ([]*tailcfg.Node, error) { 20 | tNodes := make([]*tailcfg.Node, len(nodes)) 21 | 22 | for index, node := range nodes { 23 | node, err := tailNode( 24 | node, 25 | capVer, 26 | polMan, 27 | cfg, 28 | ) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | tNodes[index] = node 34 | } 35 | 36 | return tNodes, nil 37 | } 38 | 39 | // tailNode converts a Node into a Tailscale Node. 40 | func tailNode( 41 | node *types.Node, 42 | capVer tailcfg.CapabilityVersion, 43 | polMan policy.PolicyManager, 44 | cfg *types.Config, 45 | ) (*tailcfg.Node, error) { 46 | addrs := node.Prefixes() 47 | 48 | allowedIPs := append( 49 | []netip.Prefix{}, 50 | addrs...) // we append the node own IP, as it is required by the clients 51 | 52 | primaryPrefixes := []netip.Prefix{} 53 | 54 | for _, route := range node.Routes { 55 | if route.Enabled { 56 | if route.IsPrimary { 57 | allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix)) 58 | primaryPrefixes = append(primaryPrefixes, netip.Prefix(route.Prefix)) 59 | } else if route.IsExitRoute() { 60 | allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix)) 61 | } 62 | } 63 | } 64 | 65 | var derp int 66 | 67 | // TODO(kradalby): legacyDERP was removed in tailscale/tailscale@2fc4455e6dd9ab7f879d4e2f7cffc2be81f14077 68 | // and should be removed after 111 is the minimum capver. 69 | var legacyDERP string 70 | if node.Hostinfo != nil && node.Hostinfo.NetInfo != nil { 71 | legacyDERP = fmt.Sprintf("127.3.3.40:%d", node.Hostinfo.NetInfo.PreferredDERP) 72 | derp = node.Hostinfo.NetInfo.PreferredDERP 73 | } else { 74 | legacyDERP = "127.3.3.40:0" // Zero means disconnected or unknown. 75 | } 76 | 77 | var keyExpiry time.Time 78 | if node.Expiry != nil { 79 | keyExpiry = *node.Expiry 80 | } else { 81 | keyExpiry = time.Time{} 82 | } 83 | 84 | hostname, err := node.GetFQDN(cfg.BaseDomain) 85 | if err != nil { 86 | return nil, fmt.Errorf("tailNode, failed to create FQDN: %s", err) 87 | } 88 | 89 | tags := polMan.Tags(node) 90 | tags = lo.Uniq(append(tags, node.ForcedTags...)) 91 | 92 | tNode := tailcfg.Node{ 93 | ID: tailcfg.NodeID(node.ID), // this is the actual ID 94 | StableID: node.ID.StableID(), 95 | Name: hostname, 96 | Cap: capVer, 97 | 98 | User: tailcfg.UserID(node.UserID), 99 | 100 | Key: node.NodeKey, 101 | KeyExpiry: keyExpiry.UTC(), 102 | 103 | Machine: node.MachineKey, 104 | DiscoKey: node.DiscoKey, 105 | Addresses: addrs, 106 | AllowedIPs: allowedIPs, 107 | Endpoints: node.Endpoints, 108 | HomeDERP: derp, 109 | LegacyDERPString: legacyDERP, 110 | Hostinfo: node.Hostinfo.View(), 111 | Created: node.CreatedAt.UTC(), 112 | 113 | Online: node.IsOnline, 114 | 115 | Tags: tags, 116 | 117 | PrimaryRoutes: primaryPrefixes, 118 | 119 | MachineAuthorized: !node.IsExpired(), 120 | Expired: node.IsExpired(), 121 | } 122 | 123 | tNode.CapMap = tailcfg.NodeCapMap{ 124 | tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{}, 125 | tailcfg.CapabilityAdmin: []tailcfg.RawMessage{}, 126 | tailcfg.CapabilitySSH: []tailcfg.RawMessage{}, 127 | } 128 | 129 | if cfg.RandomizeClientPort { 130 | tNode.CapMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{} 131 | } 132 | 133 | if node.IsOnline == nil || !*node.IsOnline { 134 | // LastSeen is only set when node is 135 | // not connected to the control server. 136 | tNode.LastSeen = node.LastSeen 137 | } 138 | 139 | return &tNode, nil 140 | } 141 | --------------------------------------------------------------------------------