├── .dockerignore
├── .envrc
├── .github
├── CODEOWNERS
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.yaml
│ ├── config.yml
│ └── feature_request.yaml
├── pull_request_template.md
├── renovate.json
└── workflows
│ ├── build.yml
│ ├── check-tests.yaml
│ ├── docs-deploy.yml
│ ├── docs-test.yml
│ ├── gh-action-integration-generator.go
│ ├── gh-actions-updater.yaml
│ ├── lint.yml
│ ├── release.yml
│ ├── stale.yml
│ ├── test-integration.yaml
│ ├── test.yml
│ └── update-flake.yml
├── .gitignore
├── .golangci.yaml
├── .goreleaser.yml
├── .prettierignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile.derper
├── Dockerfile.integration
├── Dockerfile.tailscale-HEAD
├── LICENSE
├── Makefile
├── README.md
├── buf.gen.yaml
├── cmd
└── headscale
│ ├── cli
│ ├── api_key.go
│ ├── configtest.go
│ ├── debug.go
│ ├── dump_config.go
│ ├── generate.go
│ ├── mockoidc.go
│ ├── nodes.go
│ ├── policy.go
│ ├── preauthkeys.go
│ ├── pterm_style.go
│ ├── root.go
│ ├── serve.go
│ ├── users.go
│ ├── utils.go
│ └── version.go
│ ├── headscale.go
│ └── headscale_test.go
├── config-example.yaml
├── derp-example.yaml
├── docs
├── about
│ ├── clients.md
│ ├── contributing.md
│ ├── faq.md
│ ├── features.md
│ ├── help.md
│ ├── releases.md
│ └── sponsor.md
├── images
│ └── headscale-acl-network.png
├── index.md
├── logo
│ ├── headscale3-dots.pdf
│ ├── headscale3-dots.png
│ ├── headscale3-dots.svg
│ ├── headscale3_header_stacked_left.pdf
│ ├── headscale3_header_stacked_left.png
│ └── headscale3_header_stacked_left.svg
├── ref
│ ├── acls.md
│ ├── configuration.md
│ ├── dns.md
│ ├── integration
│ │ ├── reverse-proxy.md
│ │ ├── tools.md
│ │ └── web-ui.md
│ ├── oidc.md
│ ├── remote-cli.md
│ ├── routes.md
│ └── tls.md
├── requirements.txt
├── setup
│ ├── install
│ │ ├── community.md
│ │ ├── container.md
│ │ ├── official.md
│ │ └── source.md
│ ├── requirements.md
│ └── upgrade.md
└── usage
│ ├── connect
│ ├── android.md
│ ├── apple.md
│ └── windows.md
│ └── getting-started.md
├── flake.lock
├── flake.nix
├── gen
├── go
│ └── headscale
│ │ └── v1
│ │ ├── apikey.pb.go
│ │ ├── device.pb.go
│ │ ├── headscale.pb.go
│ │ ├── headscale.pb.gw.go
│ │ ├── headscale_grpc.pb.go
│ │ ├── node.pb.go
│ │ ├── policy.pb.go
│ │ ├── preauthkey.pb.go
│ │ └── user.pb.go
└── openapiv2
│ └── headscale
│ └── v1
│ ├── apikey.swagger.json
│ ├── device.swagger.json
│ ├── headscale.swagger.json
│ ├── node.swagger.json
│ ├── policy.swagger.json
│ ├── preauthkey.swagger.json
│ └── user.swagger.json
├── go.mod
├── go.sum
├── hscontrol
├── app.go
├── assets
│ └── oidc_callback_template.html
├── auth.go
├── auth_test.go
├── capver
│ ├── capver.go
│ ├── capver_generated.go
│ ├── capver_test.go
│ └── gen
│ │ └── main.go
├── db
│ ├── api_key.go
│ ├── api_key_test.go
│ ├── db.go
│ ├── db_test.go
│ ├── ephemeral_garbage_collector_test.go
│ ├── ip.go
│ ├── ip_test.go
│ ├── node.go
│ ├── node_test.go
│ ├── policy.go
│ ├── preauth_keys.go
│ ├── preauth_keys_test.go
│ ├── suite_test.go
│ ├── testdata
│ │ ├── 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
│ │ ├── 0-23-0-to-0-24-0-no-more-special-types.sqlite
│ │ ├── 0-23-0-to-0-24-0-preauthkey-tags-table.sqlite
│ │ ├── failing-node-preauth-constraint.sqlite
│ │ └── pre-24-postgresdb.pssql.dump
│ ├── text_serialiser.go
│ ├── users.go
│ └── users_test.go
├── debug.go
├── derp
│ ├── derp.go
│ └── server
│ │ └── derp_server.go
├── dns
│ └── extrarecords.go
├── grpcv1.go
├── grpcv1_test.go
├── handlers.go
├── mapper
│ ├── mapper.go
│ ├── mapper_test.go
│ ├── suite_test.go
│ ├── tail.go
│ └── tail_test.go
├── metrics.go
├── noise.go
├── notifier
│ ├── metrics.go
│ ├── notifier.go
│ └── notifier_test.go
├── oidc.go
├── platform_config.go
├── policy
│ ├── matcher
│ │ ├── matcher.go
│ │ └── matcher_test.go
│ ├── pm.go
│ ├── policy.go
│ ├── policy_test.go
│ ├── route_approval_test.go
│ └── v2
│ │ ├── filter.go
│ │ ├── filter_test.go
│ │ ├── policy.go
│ │ ├── policy_test.go
│ │ ├── types.go
│ │ ├── types_test.go
│ │ ├── utils.go
│ │ └── utils_test.go
├── poll.go
├── routes
│ ├── primary.go
│ └── primary_test.go
├── suite_test.go
├── tailsql.go
├── templates
│ ├── apple.go
│ ├── general.go
│ ├── register_web.go
│ └── windows.go
├── types
│ ├── api_key.go
│ ├── common.go
│ ├── config.go
│ ├── config_test.go
│ ├── const.go
│ ├── node.go
│ ├── node_test.go
│ ├── policy.go
│ ├── preauth_key.go
│ ├── routes.go
│ ├── testdata
│ │ ├── base-domain-in-server-url.yaml
│ │ ├── base-domain-not-in-server-url.yaml
│ │ ├── dns-override-true-error.yaml
│ │ ├── dns-override-true.yaml
│ │ ├── dns_full.yaml
│ │ ├── dns_full_no_magic.yaml
│ │ ├── minimal.yaml
│ │ └── policy-path-is-loaded.yaml
│ ├── users.go
│ ├── users_test.go
│ └── version.go
└── util
│ ├── addr.go
│ ├── addr_test.go
│ ├── const.go
│ ├── dns.go
│ ├── dns_test.go
│ ├── file.go
│ ├── key.go
│ ├── log.go
│ ├── net.go
│ ├── string.go
│ ├── string_test.go
│ ├── test.go
│ ├── util.go
│ └── util_test.go
├── integration
├── README.md
├── acl_test.go
├── auth_key_test.go
├── auth_oidc_test.go
├── auth_web_flow_test.go
├── cli_test.go
├── control.go
├── derp_verify_endpoint_test.go
├── dns_test.go
├── dockertestutil
│ ├── config.go
│ ├── execute.go
│ ├── logs.go
│ └── network.go
├── dsic
│ └── dsic.go
├── embedded_derp_test.go
├── general_test.go
├── hsic
│ ├── config.go
│ └── hsic.go
├── integrationutil
│ └── util.go
├── route_test.go
├── run.sh
├── scenario.go
├── scenario_test.go
├── ssh_test.go
├── tailscale.go
├── tsic
│ └── tsic.go
└── utils.go
├── mkdocs.yml
├── packaging
├── README.md
├── deb
│ ├── postinst
│ ├── postrm
│ └── prerm
└── systemd
│ └── headscale.service
├── proto
├── buf.lock
├── buf.yaml
└── headscale
│ └── v1
│ ├── apikey.proto
│ ├── device.proto
│ ├── headscale.proto
│ ├── node.proto
│ ├── policy.proto
│ ├── preauthkey.proto
│ └── user.proto
└── swagger.go
/.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 |
--------------------------------------------------------------------------------
/.envrc:
--------------------------------------------------------------------------------
1 | use flake
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | ko_fi: headscale
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yaml:
--------------------------------------------------------------------------------
1 | name: 🐞 Bug
2 | description: File a bug/issue
3 | title: "[Bug]
"
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 | Please provide information about your environment.
48 | If you are using a container, always provide the headscale version and not only the Docker image version.
49 | Please do not put "latest".
50 |
51 | If you are experiencing a problem during an upgrade, please provide the versions of the old and new versions of Headscale and Tailscale.
52 |
53 | examples:
54 | - **OS**: Ubuntu 24.04
55 | - **Headscale version**: 0.24.3
56 | - **Tailscale version**: 1.80.0
57 | value: |
58 | - OS:
59 | - Headscale version:
60 | - Tailscale version:
61 | render: markdown
62 | validations:
63 | required: true
64 | - type: checkboxes
65 | attributes:
66 | label: Runtime environment
67 | options:
68 | - label: Headscale is behind a (reverse) proxy
69 | required: false
70 | - label: Headscale runs in a container
71 | required: false
72 | - type: textarea
73 | attributes:
74 | label: Debug information
75 | description: |
76 | Links? References? Anything that will give us more context about the issue you are encountering.
77 | If **any** of these are omitted we will likely close your issue, do **not** ignore them.
78 |
79 | - Client netmap dump (see below)
80 | - Policy configuration
81 | - Headscale configuration
82 | - Headscale log (with `trace` enabled)
83 |
84 | Dump the netmap of tailscale clients:
85 | `tailscale debug netmap > DESCRIPTIVE_NAME.json`
86 |
87 | Dump the status of tailscale clients:
88 | `tailscale status --json > DESCRIPTIVE_NAME.json`
89 |
90 | Get the logs of a Tailscale client that is not working as expected.
91 | `tailscale daemon-logs`
92 |
93 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
94 | **Ensure** you use formatting for files you attach.
95 | Do **not** paste in long files.
96 | validations:
97 | required: true
98 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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: nixbuild/nix-quick-install-action@master
35 | if: steps.changed-files.outputs.files == 'true'
36 | - uses: nix-community/cache-nix-action@main
37 | if: steps.changed-files.outputs.files == 'true'
38 | with:
39 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
40 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
41 |
42 | - name: Run nix build
43 | id: build
44 | if: steps.changed-files.outputs.files == 'true'
45 | run: |
46 | nix build |& tee build-result
47 | BUILD_STATUS="${PIPESTATUS[0]}"
48 |
49 | OLD_HASH=$(cat build-result | grep specified: | awk -F ':' '{print $2}' | sed 's/ //g')
50 | NEW_HASH=$(cat build-result | grep got: | awk -F ':' '{print $2}' | sed 's/ //g')
51 |
52 | echo "OLD_HASH=$OLD_HASH" >> $GITHUB_OUTPUT
53 | echo "NEW_HASH=$NEW_HASH" >> $GITHUB_OUTPUT
54 |
55 | exit $BUILD_STATUS
56 |
57 | - name: Nix gosum diverging
58 | uses: actions/github-script@v6
59 | if: failure() && steps.build.outcome == 'failure'
60 | with:
61 | github-token: ${{secrets.GITHUB_TOKEN}}
62 | script: |
63 | github.rest.pulls.createReviewComment({
64 | pull_number: context.issue.number,
65 | owner: context.repo.owner,
66 | repo: context.repo.repo,
67 | 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 }}'
68 | })
69 |
70 | - uses: actions/upload-artifact@v4
71 | if: steps.changed-files.outputs.files == 'true'
72 | with:
73 | name: headscale-linux
74 | path: result/bin/headscale
75 | build-cross:
76 | runs-on: ubuntu-latest
77 | strategy:
78 | matrix:
79 | env:
80 | - "GOARCH=arm GOOS=linux GOARM=5"
81 | - "GOARCH=arm GOOS=linux GOARM=6"
82 | - "GOARCH=arm GOOS=linux GOARM=7"
83 | - "GOARCH=arm64 GOOS=linux"
84 | - "GOARCH=386 GOOS=linux"
85 | - "GOARCH=amd64 GOOS=linux"
86 | - "GOARCH=arm64 GOOS=darwin"
87 | - "GOARCH=amd64 GOOS=darwin"
88 | steps:
89 | - uses: actions/checkout@v4
90 | - uses: nixbuild/nix-quick-install-action@master
91 | - uses: nix-community/cache-nix-action@main
92 | with:
93 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
94 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
95 |
96 | - name: Run go cross compile
97 | run: env ${{ matrix.env }} nix develop --command -- go build -o "headscale" ./cmd/headscale
98 | - uses: actions/upload-artifact@v4
99 | with:
100 | name: "headscale-${{ matrix.env }}"
101 | path: "headscale"
102 |
--------------------------------------------------------------------------------
/.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: nixbuild/nix-quick-install-action@master
28 | if: steps.changed-files.outputs.files == 'true'
29 | - uses: nix-community/cache-nix-action@main
30 | if: steps.changed-files.outputs.files == 'true'
31 | with:
32 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
33 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
34 |
35 | - name: Generate and check integration tests
36 | if: steps.changed-files.outputs.files == 'true'
37 | run: |
38 | nix develop --command bash -c "cd .github/workflows && go generate"
39 | git diff --exit-code .github/workflows/test-integration.yaml
40 |
41 | - name: Show missing tests
42 | if: failure()
43 | run: |
44 | git diff .github/workflows/test-integration.yaml
45 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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, testPath string) {
42 | testsForYq := fmt.Sprintf("[%s]", strings.Join(tests, ", "))
43 |
44 | yqCommand := fmt.Sprintf(
45 | "yq eval '.jobs.integration-test.strategy.matrix.test = %s' %s -i",
46 | testsForYq,
47 | testPath,
48 | )
49 | cmd := exec.Command("bash", "-c", yqCommand)
50 |
51 | var stdout bytes.Buffer
52 | var stderr bytes.Buffer
53 | cmd.Stdout = &stdout
54 | cmd.Stderr = &stderr
55 | err := cmd.Run()
56 | if err != nil {
57 | log.Printf("stdout: %s", stdout.String())
58 | log.Printf("stderr: %s", stderr.String())
59 | log.Fatalf("failed to run yq command: %s", err)
60 | }
61 |
62 | fmt.Printf("YAML file (%s) updated successfully\n", testPath)
63 | }
64 |
65 | func main() {
66 | tests := findTests()
67 |
68 | quotedTests := make([]string, len(tests))
69 | for i, test := range tests {
70 | quotedTests[i] = fmt.Sprintf("\"%s\"", test)
71 | }
72 |
73 | updateYAML(quotedTests, "./test-integration.yaml")
74 | }
75 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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: nixbuild/nix-quick-install-action@master
28 | if: steps.changed-files.outputs.files == 'true'
29 | - uses: nix-community/cache-nix-action@main
30 | if: steps.changed-files.outputs.files == 'true'
31 | with:
32 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
33 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
34 |
35 | - name: golangci-lint
36 | if: steps.changed-files.outputs.files == 'true'
37 | run: nix develop --command -- golangci-lint run --new-from-rev=${{github.event.pull_request.base.sha}} --out-format=colored-line-number
38 |
39 | prettier-lint:
40 | runs-on: ubuntu-latest
41 | steps:
42 | - uses: actions/checkout@v4
43 | with:
44 | fetch-depth: 2
45 | - name: Get changed files
46 | id: changed-files
47 | uses: dorny/paths-filter@v3
48 | with:
49 | filters: |
50 | files:
51 | - '*.nix'
52 | - '**/*.md'
53 | - '**/*.yml'
54 | - '**/*.yaml'
55 | - '**/*.ts'
56 | - '**/*.js'
57 | - '**/*.sass'
58 | - '**/*.css'
59 | - '**/*.scss'
60 | - '**/*.html'
61 | - uses: nixbuild/nix-quick-install-action@master
62 | if: steps.changed-files.outputs.files == 'true'
63 | - uses: nix-community/cache-nix-action@main
64 | if: steps.changed-files.outputs.files == 'true'
65 | with:
66 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
67 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
68 |
69 | - name: Prettify code
70 | if: steps.changed-files.outputs.files == 'true'
71 | run: nix develop --command -- prettier --no-error-on-unmatched-pattern --ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
72 |
73 | proto-lint:
74 | runs-on: ubuntu-latest
75 | steps:
76 | - uses: actions/checkout@v4
77 | - uses: nixbuild/nix-quick-install-action@master
78 | - uses: nix-community/cache-nix-action@main
79 | with:
80 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
81 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
82 |
83 | - name: Buf lint
84 | run: nix develop --command -- buf lint proto
85 |
--------------------------------------------------------------------------------
/.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: nixbuild/nix-quick-install-action@master
34 | - uses: nix-community/cache-nix-action@main
35 | with:
36 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
37 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
38 |
39 | - name: Run goreleaser
40 | run: nix develop --command -- goreleaser release --clean
41 | env:
42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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: nixbuild/nix-quick-install-action@master
31 | if: steps.changed-files.outputs.files == 'true'
32 | - uses: nix-community/cache-nix-action@main
33 | if: steps.changed-files.outputs.files == 'true'
34 | with:
35 | primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
36 | restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
37 |
38 | - name: Run tests
39 | if: steps.changed-files.outputs.files == 'true'
40 | env:
41 | # As of 2025-01-06, these env vars was not automatically
42 | # set anymore which breaks the initdb for postgres on
43 | # some of the database migration tests.
44 | LC_ALL: "en_US.UTF-8"
45 | LC_CTYPE: "en_US.UTF-8"
46 | run: nix develop --command -- gotestsum
47 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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.yaml
24 | config*.yaml
25 | !config-example.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 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | version: "2"
3 | linters:
4 | default: all
5 | disable:
6 | - cyclop
7 | - depguard
8 | - dupl
9 | - exhaustruct
10 | - funlen
11 | - gochecknoglobals
12 | - gochecknoinits
13 | - gocognit
14 | - godox
15 | - interfacebloat
16 | - ireturn
17 | - lll
18 | - maintidx
19 | - makezero
20 | - musttag
21 | - nestif
22 | - nolintlint
23 | - paralleltest
24 | - revive
25 | - tagliatelle
26 | - testpackage
27 | - wrapcheck
28 | - wsl
29 | settings:
30 | gocritic:
31 | disabled-checks:
32 | - appendAssign
33 | - ifElseChain
34 | nlreturn:
35 | block-size: 4
36 | varnamelen:
37 | ignore-names:
38 | - err
39 | - db
40 | - id
41 | - ip
42 | - ok
43 | - c
44 | - tt
45 | - tx
46 | - rx
47 | - sb
48 | - wg
49 | - pr
50 | - p
51 | - p2
52 | ignore-type-assert-ok: true
53 | ignore-map-index-ok: true
54 | exclusions:
55 | generated: lax
56 | presets:
57 | - comments
58 | - common-false-positives
59 | - legacy
60 | - std-error-handling
61 | paths:
62 | - third_party$
63 | - builtin$
64 | - examples$
65 | - gen
66 |
67 | formatters:
68 | enable:
69 | - gci
70 | - gofmt
71 | - gofumpt
72 | - goimports
73 | exclusions:
74 | generated: lax
75 | paths:
76 | - third_party$
77 | - builtin$
78 | - examples$
79 | - gen
80 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .github/workflows/test-integration-v2*
2 | docs/about/features.md
3 | docs/ref/configuration.md
4 | docs/ref/remote-cli.md
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.24-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 -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 |
--------------------------------------------------------------------------------
/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.24-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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/cmd/headscale/cli/root.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "runtime"
7 | "slices"
8 |
9 | "github.com/juanfont/headscale/hscontrol/types"
10 | "github.com/rs/zerolog"
11 | "github.com/rs/zerolog/log"
12 | "github.com/spf13/cobra"
13 | "github.com/spf13/viper"
14 | "github.com/tcnksm/go-latest"
15 | )
16 |
17 | const (
18 | deprecateNamespaceMessage = "use --user"
19 | )
20 |
21 | var cfgFile string = ""
22 |
23 | func init() {
24 | if len(os.Args) > 1 &&
25 | (os.Args[1] == "version" || os.Args[1] == "mockoidc" || os.Args[1] == "completion") {
26 | return
27 | }
28 |
29 | if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
30 | zerolog.SetGlobalLevel(zerolog.Disabled)
31 | return
32 | }
33 |
34 | cobra.OnInitialize(initConfig)
35 | rootCmd.PersistentFlags().
36 | StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
37 | rootCmd.PersistentFlags().
38 | StringP("output", "o", "", "Output format. Empty for human-readable, 'json', 'json-line' or 'yaml'")
39 | rootCmd.PersistentFlags().
40 | Bool("force", false, "Disable prompts and forces the execution")
41 | }
42 |
43 | func initConfig() {
44 | if cfgFile == "" {
45 | cfgFile = os.Getenv("HEADSCALE_CONFIG")
46 | }
47 | if cfgFile != "" {
48 | err := types.LoadConfig(cfgFile, true)
49 | if err != nil {
50 | log.Fatal().Caller().Err(err).Msgf("Error loading config file %s", cfgFile)
51 | }
52 | } else {
53 | err := types.LoadConfig("", false)
54 | if err != nil {
55 | log.Fatal().Caller().Err(err).Msgf("Error loading config")
56 | }
57 | }
58 |
59 | machineOutput := HasMachineOutputFlag()
60 |
61 | // If the user has requested a "node" readable format,
62 | // then disable login so the output remains valid.
63 | if machineOutput {
64 | zerolog.SetGlobalLevel(zerolog.Disabled)
65 | }
66 |
67 | logFormat := viper.GetString("log.format")
68 | if logFormat == types.JSONLogFormat {
69 | log.Logger = log.Output(os.Stdout)
70 | }
71 |
72 | disableUpdateCheck := viper.GetBool("disable_check_updates")
73 | if !disableUpdateCheck && !machineOutput {
74 | if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") &&
75 | types.Version != "dev" {
76 | githubTag := &latest.GithubTag{
77 | Owner: "juanfont",
78 | Repository: "headscale",
79 | }
80 | res, err := latest.Check(githubTag, types.Version)
81 | if err == nil && res.Outdated {
82 | //nolint
83 | log.Warn().Msgf(
84 | "An updated version of Headscale has been found (%s vs. your current %s). Check it out https://github.com/juanfont/headscale/releases\n",
85 | res.Current,
86 | types.Version,
87 | )
88 | }
89 | }
90 | }
91 | }
92 |
93 | var rootCmd = &cobra.Command{
94 | Use: "headscale",
95 | Short: "headscale - a Tailscale control server",
96 | Long: `
97 | headscale is an open source implementation of the Tailscale control server
98 |
99 | https://github.com/juanfont/headscale`,
100 | }
101 |
102 | func Execute() {
103 | if err := rootCmd.Execute(); err != nil {
104 | fmt.Fprintln(os.Stderr, err)
105 | os.Exit(1)
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/cmd/headscale/cli/version.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "github.com/juanfont/headscale/hscontrol/types"
5 | "github.com/spf13/cobra"
6 | )
7 |
8 | func init() {
9 | rootCmd.AddCommand(versionCmd)
10 | }
11 |
12 | var versionCmd = &cobra.Command{
13 | Use: "version",
14 | Short: "Print the version.",
15 | Long: "The version of headscale.",
16 | Run: func(cmd *cobra.Command, args []string) {
17 | output, _ := cmd.Flags().GetString("output")
18 | SuccessOutput(map[string]string{
19 | "version": types.Version,
20 | "commit": types.GitCommitHash,
21 | }, types.Version, output)
22 | },
23 | }
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/docs/about/contributing.md:
--------------------------------------------------------------------------------
1 | {%
2 | include-markdown "../../CONTRIBUTING.md"
3 | %}
4 |
--------------------------------------------------------------------------------
/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](../ref/dns.md)
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] [Routes](../ref/routes.md)
18 | - [x] [Subnet routers](../ref/routes.md#subnet-router)
19 | - [x] [Exit nodes](../ref/routes.md#exit-node)
20 | - [x] Dual stack (IPv4 and IPv6)
21 | - [x] Ephemeral nodes
22 | - [x] Embedded [DERP server](https://tailscale.com/kb/1232/derp-servers)
23 | - [x] Access control lists ([GitHub label "policy"](https://github.com/juanfont/headscale/labels/policy%20%F0%9F%93%9D))
24 | - [x] ACL management via API
25 | - [x] Some [Autogroups](https://tailscale.com/kb/1396/targets#autogroups), currently: `autogroup:internet`,
26 | `autogroup:nonroot`, `autogroup:member`, `autogroup:tagged`
27 | - [x] [Auto approvers](https://tailscale.com/kb/1337/acl-syntax#auto-approvers) for [subnet
28 | routers](../ref/routes.md#automatically-approve-routes-of-a-subnet-router) and [exit
29 | nodes](../ref/routes.md#automatically-approve-an-exit-node-with-auto-approvers)
30 | - [x] [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh)
31 | * [ ] Node registration using Single-Sign-On (OpenID Connect) ([GitHub label "OIDC"](https://github.com/juanfont/headscale/labels/OIDC))
32 | - [x] Basic registration
33 | - [x] Update user profile from identity provider
34 | - [ ] Dynamic ACL support
35 | - [ ] OIDC groups cannot be used in ACLs
36 | - [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))
37 | - [ ] [Serve](https://tailscale.com/kb/1312/serve) ([#1234](https://github.com/juanfont/headscale/issues/1921))
38 | - [ ] [Network flow logs](https://tailscale.com/kb/1219/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687))
39 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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) and
6 | [GitHub Container Registry](https://github.com/juanfont/headscale/pkgs/container/headscale).
7 |
8 | An Atom/RSS feed of headscale releases is available [here](https://github.com/juanfont/headscale/releases.atom).
9 |
10 | See the "announcements" channel on our [Discord server](https://discord.gg/c84AZQhmpx) for news about headscale.
11 |
--------------------------------------------------------------------------------
/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/images/headscale-acl-network.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/docs/images/headscale-acl-network.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/docs/logo/headscale3-dots.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/docs/logo/headscale3-dots.pdf
--------------------------------------------------------------------------------
/docs/logo/headscale3-dots.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/docs/logo/headscale3-dots.png
--------------------------------------------------------------------------------
/docs/logo/headscale3-dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/logo/headscale3_header_stacked_left.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/docs/logo/headscale3_header_stacked_left.pdf
--------------------------------------------------------------------------------
/docs/logo/headscale3_header_stacked_left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/docs/logo/headscale3_header_stacked_left.png
--------------------------------------------------------------------------------
/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 | - To load the configuration from a different path, use:
9 | - the command line flag `-c`, `--config`
10 | - the environment variable `HEADSCALE_CONFIG`
11 | - Validate the configuration file with: `headscale configtest`
12 |
13 | !!! example "Get the [example configuration from the GitHub repository](https://github.com/juanfont/headscale/blob/main/config-example.yaml)"
14 |
15 | Always select the [same GitHub tag](https://github.com/juanfont/headscale/tags) as the released version you use to
16 | ensure you have the correct example configuration. The `main` branch might contain unreleased changes.
17 |
18 | === "View on GitHub"
19 |
20 | * Development version:
21 | * Version {{ headscale.version }}:
22 |
23 | === "Download with `wget`"
24 |
25 | ```shell
26 | # Development version
27 | wget -O config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
28 |
29 | # Version {{ headscale.version }}
30 | wget -O config.yaml https://raw.githubusercontent.com/juanfont/headscale/v{{ headscale.version }}/config-example.yaml
31 | ```
32 |
33 | === "Download with `curl`"
34 |
35 | ```shell
36 | # Development version
37 | curl -o config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
38 |
39 | # Version {{ headscale.version }}
40 | curl -o config.yaml https://raw.githubusercontent.com/juanfont/headscale/v{{ headscale.version }}/config-example.yaml
41 | ```
42 |
--------------------------------------------------------------------------------
/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, client libraries, 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 | | headscale-pf | [Github](https://github.com/YouSysAdmin/headscale-pf) | Populates user groups based on user groups in Jumpcloud or Authentik |
15 | | headscale-client-go | [Github](https://github.com/hibare/headscale-client-go) | A Go client implementation for the Headscale HTTP API. |
16 |
--------------------------------------------------------------------------------
/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-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server |
13 | | HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend environment required |
14 | | Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale |
15 | | headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale |
16 | | ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins |
17 | | unraid-headscale-admin | [Github](https://github.com/ich777/unraid-headscale-admin) | A simple headscale admin UI for Unraid, it offers Local (`docker exec`) and API Mode |
18 | | headscale-console | [Github](https://github.com/rickli-cloud/headscale-console) | WebAssembly-based client supporting SSH, VNC and RDP with optional self-service capabilities |
19 |
20 | You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel.
21 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | [](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 | . 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 |
--------------------------------------------------------------------------------
/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 git
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/hscontrol/types.Version=$latestTag" -X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=HASH" 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 |
--------------------------------------------------------------------------------
/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 `` 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
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 |
--------------------------------------------------------------------------------
/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
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\\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 |
--------------------------------------------------------------------------------
/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": 1746300365,
24 | "narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
25 | "owner": "NixOS",
26 | "repo": "nixpkgs",
27 | "rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/hscontrol/capver/capver.go:
--------------------------------------------------------------------------------
1 | package capver
2 |
3 | import (
4 | "sort"
5 | "strings"
6 |
7 | "slices"
8 |
9 | xmaps "golang.org/x/exp/maps"
10 | "tailscale.com/tailcfg"
11 | "tailscale.com/util/set"
12 | )
13 |
14 | const MinSupportedCapabilityVersion tailcfg.CapabilityVersion = 88
15 |
16 | // CanOldCodeBeCleanedUp is intended to be called on startup to see if
17 | // there are old code that can ble cleaned up, entries should contain
18 | // a CapVer where something can be cleaned up and a panic if it can.
19 | // This is only intended to catch things in tests.
20 | //
21 | // All uses of Capability version checks should be listed here.
22 | func CanOldCodeBeCleanedUp() {
23 | if MinSupportedCapabilityVersion >= 111 {
24 | panic("LegacyDERP can be cleaned up in tail.go")
25 | }
26 | }
27 |
28 | func tailscaleVersSorted() []string {
29 | vers := xmaps.Keys(tailscaleToCapVer)
30 | sort.Strings(vers)
31 | return vers
32 | }
33 |
34 | func capVersSorted() []tailcfg.CapabilityVersion {
35 | capVers := xmaps.Keys(capVerToTailscaleVer)
36 | slices.Sort(capVers)
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/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.60.0": 87,
9 | "v1.60.1": 87,
10 | "v1.62.0": 88,
11 | "v1.62.1": 88,
12 | "v1.64.0": 90,
13 | "v1.64.1": 90,
14 | "v1.64.2": 90,
15 | "v1.66.0": 95,
16 | "v1.66.1": 95,
17 | "v1.66.2": 95,
18 | "v1.66.3": 95,
19 | "v1.66.4": 95,
20 | "v1.68.0": 97,
21 | "v1.68.1": 97,
22 | "v1.68.2": 97,
23 | "v1.70.0": 102,
24 | "v1.72.0": 104,
25 | "v1.72.1": 104,
26 | "v1.74.0": 106,
27 | "v1.74.1": 106,
28 | "v1.76.0": 106,
29 | "v1.76.1": 106,
30 | "v1.76.6": 106,
31 | "v1.78.0": 109,
32 | "v1.78.1": 109,
33 | "v1.80.0": 113,
34 | "v1.80.1": 113,
35 | "v1.80.2": 113,
36 | "v1.80.3": 113,
37 | "v1.82.0": 115,
38 | "v1.82.5": 115,
39 | }
40 |
41 |
42 | var capVerToTailscaleVer = map[tailcfg.CapabilityVersion]string{
43 | 87: "v1.60.0",
44 | 88: "v1.62.0",
45 | 90: "v1.64.0",
46 | 95: "v1.66.0",
47 | 97: "v1.68.0",
48 | 102: "v1.70.0",
49 | 104: "v1.72.0",
50 | 106: "v1.74.0",
51 | 109: "v1.78.0",
52 | 113: "v1.80.0",
53 | 115: "v1.82.0",
54 | }
55 |
--------------------------------------------------------------------------------
/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.78", "v1.80", "v1.82"}},
17 | {2, true, []string{"1.80", "1.82"}},
18 | // Lazy way to see all supported versions
19 | {10, true, []string{
20 | "1.64",
21 | "1.66",
22 | "1.68",
23 | "1.70",
24 | "1.72",
25 | "1.74",
26 | "1.76",
27 | "1.78",
28 | "1.80",
29 | "1.82",
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 | {88, "v1.62.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-are-dropped-2063.sqlite
--------------------------------------------------------------------------------
/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/hscontrol/db/testdata/0-22-3-to-0-23-0-routes-fail-foreign-key-2076.sqlite
--------------------------------------------------------------------------------
/hscontrol/db/testdata/0-23-0-to-0-24-0-no-more-special-types.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/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/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/hscontrol/db/testdata/0-23-0-to-0-24-0-preauthkey-tags-table.sqlite
--------------------------------------------------------------------------------
/hscontrol/db/testdata/failing-node-preauth-constraint.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/hscontrol/db/testdata/failing-node-preauth-constraint.sqlite
--------------------------------------------------------------------------------
/hscontrol/db/testdata/pre-24-postgresdb.pssql.dump:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/juanfont/headscale/b8044c29ddc59d9c6346337d589b73a7e5b0511e/hscontrol/db/testdata/pre-24-postgresdb.pssql.dump
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/mapper/tail.go:
--------------------------------------------------------------------------------
1 | package mapper
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/juanfont/headscale/hscontrol/policy"
8 | "github.com/juanfont/headscale/hscontrol/types"
9 | "github.com/samber/lo"
10 | "tailscale.com/net/tsaddr"
11 | "tailscale.com/tailcfg"
12 | )
13 |
14 | func tailNodes(
15 | nodes types.Nodes,
16 | capVer tailcfg.CapabilityVersion,
17 | polMan policy.PolicyManager,
18 | primaryRouteFunc routeFilterFunc,
19 | cfg *types.Config,
20 | ) ([]*tailcfg.Node, error) {
21 | tNodes := make([]*tailcfg.Node, len(nodes))
22 |
23 | for index, node := range nodes {
24 | node, err := tailNode(
25 | node,
26 | capVer,
27 | polMan,
28 | primaryRouteFunc,
29 | cfg,
30 | )
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | tNodes[index] = node
36 | }
37 |
38 | return tNodes, nil
39 | }
40 |
41 | // tailNode converts a Node into a Tailscale Node.
42 | func tailNode(
43 | node *types.Node,
44 | capVer tailcfg.CapabilityVersion,
45 | polMan policy.PolicyManager,
46 | primaryRouteFunc routeFilterFunc,
47 | cfg *types.Config,
48 | ) (*tailcfg.Node, error) {
49 | addrs := node.Prefixes()
50 |
51 | var derp int
52 |
53 | // TODO(kradalby): legacyDERP was removed in tailscale/tailscale@2fc4455e6dd9ab7f879d4e2f7cffc2be81f14077
54 | // and should be removed after 111 is the minimum capver.
55 | var legacyDERP string
56 | if node.Hostinfo != nil && node.Hostinfo.NetInfo != nil {
57 | legacyDERP = fmt.Sprintf("127.3.3.40:%d", node.Hostinfo.NetInfo.PreferredDERP)
58 | derp = node.Hostinfo.NetInfo.PreferredDERP
59 | } else {
60 | legacyDERP = "127.3.3.40:0" // Zero means disconnected or unknown.
61 | }
62 |
63 | var keyExpiry time.Time
64 | if node.Expiry != nil {
65 | keyExpiry = *node.Expiry
66 | } else {
67 | keyExpiry = time.Time{}
68 | }
69 |
70 | hostname, err := node.GetFQDN(cfg.BaseDomain)
71 | if err != nil {
72 | return nil, fmt.Errorf("tailNode, failed to create FQDN: %s", err)
73 | }
74 |
75 | var tags []string
76 | for _, tag := range node.RequestTags() {
77 | if polMan.NodeCanHaveTag(node, tag) {
78 | tags = append(tags, tag)
79 | }
80 | }
81 | tags = lo.Uniq(append(tags, node.ForcedTags...))
82 |
83 | routes := primaryRouteFunc(node.ID)
84 | allowed := append(node.Prefixes(), routes...)
85 | allowed = append(allowed, node.ExitRoutes()...)
86 | tsaddr.SortPrefixes(allowed)
87 |
88 | tNode := tailcfg.Node{
89 | ID: tailcfg.NodeID(node.ID), // this is the actual ID
90 | StableID: node.ID.StableID(),
91 | Name: hostname,
92 | Cap: capVer,
93 |
94 | User: tailcfg.UserID(node.UserID),
95 |
96 | Key: node.NodeKey,
97 | KeyExpiry: keyExpiry.UTC(),
98 |
99 | Machine: node.MachineKey,
100 | DiscoKey: node.DiscoKey,
101 | Addresses: addrs,
102 | PrimaryRoutes: routes,
103 | AllowedIPs: allowed,
104 | Endpoints: node.Endpoints,
105 | HomeDERP: derp,
106 | LegacyDERPString: legacyDERP,
107 | Hostinfo: node.Hostinfo.View(),
108 | Created: node.CreatedAt.UTC(),
109 |
110 | Online: node.IsOnline,
111 |
112 | Tags: tags,
113 |
114 | MachineAuthorized: !node.IsExpired(),
115 | Expired: node.IsExpired(),
116 | }
117 |
118 | tNode.CapMap = tailcfg.NodeCapMap{
119 | tailcfg.CapabilityFileSharing: []tailcfg.RawMessage{},
120 | tailcfg.CapabilityAdmin: []tailcfg.RawMessage{},
121 | tailcfg.CapabilitySSH: []tailcfg.RawMessage{},
122 | }
123 |
124 | if cfg.RandomizeClientPort {
125 | tNode.CapMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{}
126 | }
127 |
128 | if node.IsOnline == nil || !*node.IsOnline {
129 | // LastSeen is only set when node is
130 | // not connected to the control server.
131 | tNode.LastSeen = node.LastSeen
132 | }
133 |
134 | return &tNode, nil
135 | }
136 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/hscontrol/policy/matcher/matcher.go:
--------------------------------------------------------------------------------
1 | package matcher
2 |
3 | import (
4 | "net/netip"
5 | "strings"
6 |
7 | "slices"
8 |
9 | "github.com/juanfont/headscale/hscontrol/util"
10 | "go4.org/netipx"
11 | "tailscale.com/tailcfg"
12 | )
13 |
14 | type Match struct {
15 | srcs *netipx.IPSet
16 | dests *netipx.IPSet
17 | }
18 |
19 | func (m Match) DebugString() string {
20 | var sb strings.Builder
21 |
22 | sb.WriteString("Match:\n")
23 | sb.WriteString(" Sources:\n")
24 | for _, prefix := range m.srcs.Prefixes() {
25 | sb.WriteString(" " + prefix.String() + "\n")
26 | }
27 | sb.WriteString(" Destinations:\n")
28 | for _, prefix := range m.dests.Prefixes() {
29 | sb.WriteString(" " + prefix.String() + "\n")
30 | }
31 | return sb.String()
32 | }
33 |
34 | func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
35 | matches := make([]Match, 0, len(rules))
36 | for _, rule := range rules {
37 | matches = append(matches, MatchFromFilterRule(rule))
38 | }
39 | return matches
40 | }
41 |
42 | func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
43 | dests := []string{}
44 | for _, dest := range rule.DstPorts {
45 | dests = append(dests, dest.IP)
46 | }
47 |
48 | return MatchFromStrings(rule.SrcIPs, dests)
49 | }
50 |
51 | func MatchFromStrings(sources, destinations []string) Match {
52 | srcs := new(netipx.IPSetBuilder)
53 | dests := new(netipx.IPSetBuilder)
54 |
55 | for _, srcIP := range sources {
56 | set, _ := util.ParseIPSet(srcIP, nil)
57 |
58 | srcs.AddSet(set)
59 | }
60 |
61 | for _, dest := range destinations {
62 | set, _ := util.ParseIPSet(dest, nil)
63 |
64 | dests.AddSet(set)
65 | }
66 |
67 | srcsSet, _ := srcs.IPSet()
68 | destsSet, _ := dests.IPSet()
69 |
70 | match := Match{
71 | srcs: srcsSet,
72 | dests: destsSet,
73 | }
74 |
75 | return match
76 | }
77 |
78 | func (m *Match) SrcsContainsIPs(ips ...netip.Addr) bool {
79 | return slices.ContainsFunc(ips, m.srcs.Contains)
80 | }
81 |
82 | func (m *Match) DestsContainsIP(ips ...netip.Addr) bool {
83 | return slices.ContainsFunc(ips, m.dests.Contains)
84 | }
85 |
86 | func (m *Match) SrcsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
87 | return slices.ContainsFunc(prefixes, m.srcs.OverlapsPrefix)
88 | }
89 |
90 | func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
91 | return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix)
92 | }
93 |
--------------------------------------------------------------------------------
/hscontrol/policy/matcher/matcher_test.go:
--------------------------------------------------------------------------------
1 | package matcher
2 |
--------------------------------------------------------------------------------
/hscontrol/policy/pm.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import (
4 | "net/netip"
5 |
6 | "github.com/juanfont/headscale/hscontrol/policy/matcher"
7 |
8 | policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
9 | "github.com/juanfont/headscale/hscontrol/types"
10 | "tailscale.com/tailcfg"
11 | )
12 |
13 | type PolicyManager interface {
14 | // Filter returns the current filter rules for the entire tailnet and the associated matchers.
15 | Filter() ([]tailcfg.FilterRule, []matcher.Match)
16 | SSHPolicy(*types.Node) (*tailcfg.SSHPolicy, error)
17 | SetPolicy([]byte) (bool, error)
18 | SetUsers(users []types.User) (bool, error)
19 | SetNodes(nodes types.Nodes) (bool, error)
20 | // NodeCanHaveTag reports whether the given node can have the given tag.
21 | NodeCanHaveTag(*types.Node, string) bool
22 |
23 | // NodeCanApproveRoute reports whether the given node can approve the given route.
24 | NodeCanApproveRoute(*types.Node, netip.Prefix) bool
25 |
26 | Version() int
27 | DebugString() string
28 | }
29 |
30 | // NewPolicyManager returns a new policy manager.
31 | func NewPolicyManager(pol []byte, users []types.User, nodes types.Nodes) (PolicyManager, error) {
32 | var polMan PolicyManager
33 | var err error
34 | polMan, err = policyv2.NewPolicyManager(pol, users, nodes)
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | return polMan, err
40 | }
41 |
42 | // PolicyManagersForTest returns all available PostureManagers to be used
43 | // in tests to validate them in tests that try to determine that they
44 | // behave the same.
45 | func PolicyManagersForTest(pol []byte, users []types.User, nodes types.Nodes) ([]PolicyManager, error) {
46 | var polMans []PolicyManager
47 |
48 | for _, pmf := range PolicyManagerFuncsForTest(pol) {
49 | pm, err := pmf(users, nodes)
50 | if err != nil {
51 | return nil, err
52 | }
53 | polMans = append(polMans, pm)
54 | }
55 |
56 | return polMans, nil
57 | }
58 |
59 | func PolicyManagerFuncsForTest(pol []byte) []func([]types.User, types.Nodes) (PolicyManager, error) {
60 | var polmanFuncs []func([]types.User, types.Nodes) (PolicyManager, error)
61 |
62 | polmanFuncs = append(polmanFuncs, func(u []types.User, n types.Nodes) (PolicyManager, error) {
63 | return policyv2.NewPolicyManager(pol, u, n)
64 | })
65 |
66 | return polmanFuncs
67 | }
68 |
--------------------------------------------------------------------------------
/hscontrol/policy/policy.go:
--------------------------------------------------------------------------------
1 | package policy
2 |
3 | import (
4 | "net/netip"
5 | "slices"
6 |
7 | "github.com/juanfont/headscale/hscontrol/policy/matcher"
8 |
9 | "github.com/juanfont/headscale/hscontrol/types"
10 | "github.com/juanfont/headscale/hscontrol/util"
11 | "github.com/samber/lo"
12 | "tailscale.com/net/tsaddr"
13 | "tailscale.com/tailcfg"
14 | )
15 |
16 | // ReduceNodes returns the list of peers authorized to be accessed from a given node.
17 | func ReduceNodes(
18 | node *types.Node,
19 | nodes types.Nodes,
20 | matchers []matcher.Match,
21 | ) types.Nodes {
22 | var result types.Nodes
23 |
24 | for index, peer := range nodes {
25 | if peer.ID == node.ID {
26 | continue
27 | }
28 |
29 | if node.CanAccess(matchers, nodes[index]) || peer.CanAccess(matchers, node) {
30 | result = append(result, peer)
31 | }
32 | }
33 |
34 | return result
35 | }
36 |
37 | // ReduceRoutes returns a reduced list of routes for a given node that it can access.
38 | func ReduceRoutes(
39 | node *types.Node,
40 | routes []netip.Prefix,
41 | matchers []matcher.Match,
42 | ) []netip.Prefix {
43 | var result []netip.Prefix
44 |
45 | for _, route := range routes {
46 | if node.CanAccessRoute(matchers, route) {
47 | result = append(result, route)
48 | }
49 | }
50 |
51 | return result
52 | }
53 |
54 | // ReduceFilterRules takes a node and a set of rules and removes all rules and destinations
55 | // that are not relevant to that particular node.
56 | func ReduceFilterRules(node *types.Node, rules []tailcfg.FilterRule) []tailcfg.FilterRule {
57 | ret := []tailcfg.FilterRule{}
58 |
59 | for _, rule := range rules {
60 | // record if the rule is actually relevant for the given node.
61 | var dests []tailcfg.NetPortRange
62 | DEST_LOOP:
63 | for _, dest := range rule.DstPorts {
64 | expanded, err := util.ParseIPSet(dest.IP, nil)
65 | // Fail closed, if we can't parse it, then we should not allow
66 | // access.
67 | if err != nil {
68 | continue DEST_LOOP
69 | }
70 |
71 | if node.InIPSet(expanded) {
72 | dests = append(dests, dest)
73 | continue DEST_LOOP
74 | }
75 |
76 | // If the node exposes routes, ensure they are note removed
77 | // when the filters are reduced.
78 | if node.Hostinfo != nil {
79 | if len(node.Hostinfo.RoutableIPs) > 0 {
80 | for _, routableIP := range node.Hostinfo.RoutableIPs {
81 | if expanded.OverlapsPrefix(routableIP) {
82 | dests = append(dests, dest)
83 | continue DEST_LOOP
84 | }
85 | }
86 | }
87 | }
88 | }
89 |
90 | if len(dests) > 0 {
91 | ret = append(ret, tailcfg.FilterRule{
92 | SrcIPs: rule.SrcIPs,
93 | DstPorts: dests,
94 | IPProto: rule.IPProto,
95 | })
96 | }
97 | }
98 |
99 | return ret
100 | }
101 |
102 | // AutoApproveRoutes approves any route that can be autoapproved from
103 | // the nodes perspective according to the given policy.
104 | // It reports true if any routes were approved.
105 | func AutoApproveRoutes(pm PolicyManager, node *types.Node) bool {
106 | if pm == nil {
107 | return false
108 | }
109 | var newApproved []netip.Prefix
110 | for _, route := range node.AnnouncedRoutes() {
111 | if pm.NodeCanApproveRoute(node, route) {
112 | newApproved = append(newApproved, route)
113 | }
114 | }
115 | if newApproved != nil {
116 | newApproved = append(newApproved, node.ApprovedRoutes...)
117 | tsaddr.SortPrefixes(newApproved)
118 | newApproved = slices.Compact(newApproved)
119 | newApproved = lo.Filter(newApproved, func(route netip.Prefix, index int) bool {
120 | return route.IsValid()
121 | })
122 | node.ApprovedRoutes = newApproved
123 |
124 | return true
125 | }
126 |
127 | return false
128 | }
129 |
--------------------------------------------------------------------------------
/hscontrol/policy/v2/policy_test.go:
--------------------------------------------------------------------------------
1 | package v2
2 |
3 | import (
4 | "github.com/juanfont/headscale/hscontrol/policy/matcher"
5 | "testing"
6 |
7 | "github.com/google/go-cmp/cmp"
8 | "github.com/juanfont/headscale/hscontrol/types"
9 | "github.com/stretchr/testify/require"
10 | "gorm.io/gorm"
11 | "tailscale.com/tailcfg"
12 | )
13 |
14 | func node(name, ipv4, ipv6 string, user types.User, hostinfo *tailcfg.Hostinfo) *types.Node {
15 | return &types.Node{
16 | ID: 0,
17 | Hostname: name,
18 | IPv4: ap(ipv4),
19 | IPv6: ap(ipv6),
20 | User: user,
21 | UserID: user.ID,
22 | Hostinfo: hostinfo,
23 | }
24 | }
25 |
26 | func TestPolicyManager(t *testing.T) {
27 | users := types.Users{
28 | {Model: gorm.Model{ID: 1}, Name: "testuser", Email: "testuser@headscale.net"},
29 | {Model: gorm.Model{ID: 2}, Name: "otheruser", Email: "otheruser@headscale.net"},
30 | }
31 |
32 | tests := []struct {
33 | name string
34 | pol string
35 | nodes types.Nodes
36 | wantFilter []tailcfg.FilterRule
37 | wantMatchers []matcher.Match
38 | }{
39 | {
40 | name: "empty-policy",
41 | pol: "{}",
42 | nodes: types.Nodes{},
43 | wantFilter: nil,
44 | wantMatchers: []matcher.Match{},
45 | },
46 | }
47 |
48 | for _, tt := range tests {
49 | t.Run(tt.name, func(t *testing.T) {
50 | pm, err := NewPolicyManager([]byte(tt.pol), users, tt.nodes)
51 | require.NoError(t, err)
52 |
53 | filter, matchers := pm.Filter()
54 | if diff := cmp.Diff(tt.wantFilter, filter); diff != "" {
55 | t.Errorf("Filter() filter mismatch (-want +got):\n%s", diff)
56 | }
57 | if diff := cmp.Diff(
58 | tt.wantMatchers,
59 | matchers,
60 | cmp.AllowUnexported(matcher.Match{}),
61 | ); diff != "" {
62 | t.Errorf("Filter() matchers mismatch (-want +got):\n%s", diff)
63 | }
64 |
65 | // TODO(kradalby): Test SSH Policy
66 | })
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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("TailSQL started")
96 | <-ctx.Done()
97 | logf("TailSQL shutting down...")
98 | return tsNode.Close()
99 | }
100 |
--------------------------------------------------------------------------------
/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/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 --key %s --user USERNAME", registrationID.String())),
32 | ),
33 | ),
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/hscontrol/types/preauth_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 | // PreAuthKey describes a pre-authorization key usable in a particular user.
11 | type PreAuthKey struct {
12 | ID uint64 `gorm:"primary_key"`
13 | Key string
14 | UserID uint
15 | User User `gorm:"constraint:OnDelete:SET NULL;"`
16 | Reusable bool
17 | Ephemeral bool `gorm:"default:false"`
18 | Used bool `gorm:"default:false"`
19 |
20 | // Tags are always applied to the node and is one of
21 | // the sources of tags a node might have. They are copied
22 | // from the PreAuthKey when the node logs in the first time,
23 | // and ignored after.
24 | Tags []string `gorm:"serializer:json"`
25 |
26 | CreatedAt *time.Time
27 | Expiration *time.Time
28 | }
29 |
30 | func (key *PreAuthKey) Proto() *v1.PreAuthKey {
31 | protoKey := v1.PreAuthKey{
32 | User: key.User.Proto(),
33 | Id: key.ID,
34 | Key: key.Key,
35 | Ephemeral: key.Ephemeral,
36 | Reusable: key.Reusable,
37 | Used: key.Used,
38 | AclTags: key.Tags,
39 | }
40 |
41 | if key.Expiration != nil {
42 | protoKey.Expiration = timestamppb.New(*key.Expiration)
43 | }
44 |
45 | if key.CreatedAt != nil {
46 | protoKey.CreatedAt = timestamppb.New(*key.CreatedAt)
47 | }
48 |
49 | return &protoKey
50 | }
51 |
--------------------------------------------------------------------------------
/hscontrol/types/routes.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "net/netip"
5 |
6 | "gorm.io/gorm"
7 | )
8 |
9 | // Deprecated: Approval of routes is denormalised onto the relevant node.
10 | // Struct is kept for GORM migrations only.
11 | type Route struct {
12 | gorm.Model
13 |
14 | NodeID uint64 `gorm:"not null"`
15 | Node *Node
16 |
17 | Prefix netip.Prefix `gorm:"serializer:text"`
18 |
19 | // Advertised is now only stored as part of [Node.Hostinfo].
20 | Advertised bool
21 |
22 | // Enabled is stored directly on the node as ApprovedRoutes.
23 | Enabled bool
24 |
25 | // IsPrimary is only determined in memory as it is only relevant
26 | // when the server is up.
27 | IsPrimary bool
28 | }
29 |
30 | // Deprecated: Approval of routes is denormalised onto the relevant node.
31 | type Routes []Route
32 |
--------------------------------------------------------------------------------
/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 | override_local_dns: false
17 |
--------------------------------------------------------------------------------
/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 | override_local_dns: false
17 |
--------------------------------------------------------------------------------
/hscontrol/types/testdata/dns-override-true-error.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 | override_local_dns: true
17 |
--------------------------------------------------------------------------------
/hscontrol/types/testdata/dns-override-true.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: derp2.no
16 | override_local_dns: true
17 | nameservers:
18 | global:
19 | - 1.1.1.1
20 | - 1.0.0.1
21 |
--------------------------------------------------------------------------------
/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 | override_local_dns: false
11 | nameservers:
12 | global:
13 | - 1.1.1.1
14 | - 1.0.0.1
15 | - 2606:4700:4700::1111
16 | - 2606:4700:4700::1001
17 | - https://dns.nextdns.io/abc123
18 |
19 | split:
20 | foo.bar.com:
21 | - 1.1.1.1
22 | darp.headscale.net:
23 | - 1.1.1.1
24 | - 8.8.8.8
25 |
26 | search_domains:
27 | - test.com
28 | - bar.com
29 |
30 | extra_records:
31 | - name: "grafana.myvpn.example.com"
32 | type: "A"
33 | value: "100.64.0.3"
34 |
35 | # you can also put it in one line
36 | - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.4" }
37 |
--------------------------------------------------------------------------------
/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 | override_local_dns: false
11 | nameservers:
12 | global:
13 | - 1.1.1.1
14 | - 1.0.0.1
15 | - 2606:4700:4700::1111
16 | - 2606:4700:4700::1001
17 | - https://dns.nextdns.io/abc123
18 |
19 | split:
20 | foo.bar.com:
21 | - 1.1.1.1
22 | darp.headscale.net:
23 | - 1.1.1.1
24 | - 8.8.8.8
25 |
26 | search_domains:
27 | - test.com
28 | - bar.com
29 |
30 | extra_records:
31 | - name: "grafana.myvpn.example.com"
32 | type: "A"
33 | value: "100.64.0.3"
34 |
35 | # you can also put it in one line
36 | - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.4" }
37 |
--------------------------------------------------------------------------------
/hscontrol/types/testdata/minimal.yaml:
--------------------------------------------------------------------------------
1 | noise:
2 | private_key_path: "private_key.pem"
3 | server_url: "https://derp.no"
4 |
--------------------------------------------------------------------------------
/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:
19 | magic_dns: false
20 | override_local_dns: false
21 |
--------------------------------------------------------------------------------
/hscontrol/types/version.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | var Version = "dev"
4 | var GitCommitHash = "dev"
5 |
--------------------------------------------------------------------------------
/hscontrol/util/addr.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "iter"
6 | "net/netip"
7 | "strings"
8 |
9 | "go4.org/netipx"
10 | )
11 |
12 | // This is borrowed from, and updated to use IPSet
13 | // https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162
14 | // TODO(kradalby): contribute upstream and make public.
15 | var (
16 | zeroIP4 = netip.AddrFrom4([4]byte{})
17 | zeroIP6 = netip.AddrFrom16([16]byte{})
18 | )
19 |
20 | // parseIPSet parses arg as one:
21 | //
22 | // - an IP address (IPv4 or IPv6)
23 | // - the string "*" to match everything (both IPv4 & IPv6)
24 | // - a CIDR (e.g. "192.168.0.0/16")
25 | // - a range of two IPs, inclusive, separated by hyphen ("2eff::1-2eff::0800")
26 | //
27 | // bits, if non-nil, is the legacy SrcBits CIDR length to make a IP
28 | // address (without a slash) treated as a CIDR of *bits length.
29 | // nolint
30 | func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) {
31 | var ipSet netipx.IPSetBuilder
32 | if arg == "*" {
33 | ipSet.AddPrefix(netip.PrefixFrom(zeroIP4, 0))
34 | ipSet.AddPrefix(netip.PrefixFrom(zeroIP6, 0))
35 |
36 | return ipSet.IPSet()
37 | }
38 | if strings.Contains(arg, "/") {
39 | pfx, err := netip.ParsePrefix(arg)
40 | if err != nil {
41 | return nil, err
42 | }
43 | if pfx != pfx.Masked() {
44 | return nil, fmt.Errorf("%v contains non-network bits set", pfx)
45 | }
46 |
47 | ipSet.AddPrefix(pfx)
48 |
49 | return ipSet.IPSet()
50 | }
51 | if strings.Count(arg, "-") == 1 {
52 | ip1s, ip2s, _ := strings.Cut(arg, "-")
53 |
54 | ip1, err := netip.ParseAddr(ip1s)
55 | if err != nil {
56 | return nil, err
57 | }
58 |
59 | ip2, err := netip.ParseAddr(ip2s)
60 | if err != nil {
61 | return nil, err
62 | }
63 |
64 | r := netipx.IPRangeFrom(ip1, ip2)
65 | if !r.IsValid() {
66 | return nil, fmt.Errorf("invalid IP range %q", arg)
67 | }
68 |
69 | for _, prefix := range r.Prefixes() {
70 | ipSet.AddPrefix(prefix)
71 | }
72 |
73 | return ipSet.IPSet()
74 | }
75 | ip, err := netip.ParseAddr(arg)
76 | if err != nil {
77 | return nil, fmt.Errorf("invalid IP address %q", arg)
78 | }
79 | bits8 := uint8(ip.BitLen())
80 | if bits != nil {
81 | if *bits < 0 || *bits > int(bits8) {
82 | return nil, fmt.Errorf("invalid CIDR size %d for IP %q", *bits, arg)
83 | }
84 | bits8 = uint8(*bits)
85 | }
86 |
87 | ipSet.AddPrefix(netip.PrefixFrom(ip, int(bits8)))
88 |
89 | return ipSet.IPSet()
90 | }
91 |
92 | func GetIPPrefixEndpoints(na netip.Prefix) (netip.Addr, netip.Addr) {
93 | var network, broadcast netip.Addr
94 | ipRange := netipx.RangeOfPrefix(na)
95 | network = ipRange.From()
96 | broadcast = ipRange.To()
97 |
98 | return network, broadcast
99 | }
100 |
101 | func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) {
102 | result := make([]netip.Prefix, len(prefixes))
103 |
104 | for index, prefixStr := range prefixes {
105 | prefix, err := netip.ParsePrefix(prefixStr)
106 | if err != nil {
107 | return nil, err
108 | }
109 |
110 | result[index] = prefix
111 | }
112 |
113 | return result, nil
114 | }
115 |
116 | // IPSetAddrIter returns a function that iterates over all the IPs in the IPSet.
117 | func IPSetAddrIter(ipSet *netipx.IPSet) iter.Seq[netip.Addr] {
118 | return func(yield func(netip.Addr) bool) {
119 | for _, rng := range ipSet.Ranges() {
120 | for ip := rng.From(); ip.Compare(rng.To()) <= 0; ip = ip.Next() {
121 | if !yield(ip) {
122 | return
123 | }
124 | }
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/hscontrol/util/const.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | const (
4 | RegisterMethodAuthKey = "authkey"
5 | RegisterMethodOIDC = "oidc"
6 | RegisterMethodCLI = "cli"
7 | )
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/hscontrol/util/net.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "context"
5 | "net"
6 | "net/netip"
7 | "sync"
8 |
9 | "go4.org/netipx"
10 | "tailscale.com/net/tsaddr"
11 | )
12 |
13 | func GrpcSocketDialer(ctx context.Context, addr string) (net.Conn, error) {
14 | var d net.Dialer
15 |
16 | return d.DialContext(ctx, "unix", addr)
17 | }
18 |
19 | func PrefixesToString(prefixes []netip.Prefix) []string {
20 | ret := make([]string, 0, len(prefixes))
21 | for _, prefix := range prefixes {
22 | ret = append(ret, prefix.String())
23 | }
24 |
25 | return ret
26 | }
27 |
28 | func MustStringsToPrefixes(strings []string) []netip.Prefix {
29 | ret := make([]netip.Prefix, 0, len(strings))
30 | for _, str := range strings {
31 | prefix := netip.MustParsePrefix(str)
32 | ret = append(ret, prefix)
33 | }
34 |
35 | return ret
36 | }
37 |
38 | // TheInternet returns the IPSet for the Internet.
39 | // https://www.youtube.com/watch?v=iDbyYGrswtg
40 | var TheInternet = sync.OnceValue(func() *netipx.IPSet {
41 | var internetBuilder netipx.IPSetBuilder
42 | internetBuilder.AddPrefix(netip.MustParsePrefix("2000::/3"))
43 | internetBuilder.AddPrefix(tsaddr.AllIPv4())
44 |
45 | // Delete Private network addresses
46 | // https://datatracker.ietf.org/doc/html/rfc1918
47 | internetBuilder.RemovePrefix(netip.MustParsePrefix("fc00::/7"))
48 | internetBuilder.RemovePrefix(netip.MustParsePrefix("10.0.0.0/8"))
49 | internetBuilder.RemovePrefix(netip.MustParsePrefix("172.16.0.0/12"))
50 | internetBuilder.RemovePrefix(netip.MustParsePrefix("192.168.0.0/16"))
51 |
52 | // Delete Tailscale networks
53 | internetBuilder.RemovePrefix(tsaddr.TailscaleULARange())
54 | internetBuilder.RemovePrefix(tsaddr.CGNATRange())
55 |
56 | // Delete "can't find DHCP networks"
57 | internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-local
58 | internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16"))
59 |
60 | theInternetSet, _ := internetBuilder.IPSet()
61 | return theInternetSet
62 | })
63 |
--------------------------------------------------------------------------------
/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 MustGenerateRandomStringDNSSafe(size int) string {
61 | hash, err := GenerateRandomStringDNSSafe(size)
62 | if err != nil {
63 | panic(err)
64 | }
65 |
66 | return hash
67 | }
68 |
69 | func TailNodesToString(nodes []*tailcfg.Node) string {
70 | temp := make([]string, len(nodes))
71 |
72 | for index, node := range nodes {
73 | temp[index] = node.Name
74 | }
75 |
76 | return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
77 | }
78 |
79 | func TailMapResponseToString(resp tailcfg.MapResponse) string {
80 | return fmt.Sprintf(
81 | "{ Node: %s, Peers: %s }",
82 | resp.Node.Name,
83 | TailNodesToString(resp.Peers),
84 | )
85 | }
86 |
87 | func TailcfgFilterRulesToString(rules []tailcfg.FilterRule) string {
88 | var sb strings.Builder
89 |
90 | for index, rule := range rules {
91 | sb.WriteString(fmt.Sprintf(`
92 | {
93 | SrcIPs: %v
94 | DstIPs: %v
95 | }
96 | `, rule.SrcIPs, rule.DstPorts))
97 | if index < len(rules)-1 {
98 | sb.WriteString(", ")
99 | }
100 | }
101 |
102 | return fmt.Sprintf("[ %s ](%d)", sb.String(), len(rules))
103 | }
104 |
--------------------------------------------------------------------------------
/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 range 100000 {
12 | str, err := GenerateRandomStringDNSSafe(8)
13 | require.NoError(t, err)
14 | assert.Len(t, str, 8)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/integration/control.go:
--------------------------------------------------------------------------------
1 | package integration
2 |
3 | import (
4 | "net/netip"
5 |
6 | v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
7 | policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
8 | "github.com/ory/dockertest/v3"
9 | )
10 |
11 | type ControlServer interface {
12 | Shutdown() (string, string, error)
13 | SaveLog(string) (string, string, error)
14 | SaveProfile(string) error
15 | Execute(command []string) (string, error)
16 | WriteFile(path string, content []byte) error
17 | ConnectToNetwork(network *dockertest.Network) error
18 | GetHealthEndpoint() string
19 | GetEndpoint() string
20 | WaitForRunning() error
21 | CreateUser(user string) (*v1.User, error)
22 | CreateAuthKey(user uint64, reusable bool, ephemeral bool) (*v1.PreAuthKey, error)
23 | ListNodes(users ...string) ([]*v1.Node, error)
24 | NodesByUser() (map[string][]*v1.Node, error)
25 | NodesByName() (map[string]*v1.Node, error)
26 | ListUsers() ([]*v1.User, error)
27 | MapUsers() (map[string]*v1.User, error)
28 | ApproveRoutes(uint64, []netip.Prefix) (*v1.Node, error)
29 | GetCert() []byte
30 | GetHostname() string
31 | SetPolicy(*policyv2.Policy) error
32 | }
33 |
--------------------------------------------------------------------------------
/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 | spec := ScenarioSpec{
35 | NodesPerUser: len(MustTestVersions),
36 | Users: []string{"user1"},
37 | }
38 |
39 | scenario, err := NewScenario(spec)
40 | assertNoErr(t, err)
41 | defer scenario.ShutdownAssertNoPanics(t)
42 |
43 | derper, err := scenario.CreateDERPServer("head",
44 | dsic.WithCACert(certHeadscale),
45 | dsic.WithVerifyClientURL(fmt.Sprintf("https://%s/verify", net.JoinHostPort(hostname, strconv.Itoa(headscalePort)))),
46 | )
47 | assertNoErr(t, err)
48 |
49 | derpMap := tailcfg.DERPMap{
50 | Regions: map[int]*tailcfg.DERPRegion{
51 | 900: {
52 | RegionID: 900,
53 | RegionCode: "test-derpverify",
54 | RegionName: "TestDerpVerify",
55 | Nodes: []*tailcfg.DERPNode{
56 | {
57 | Name: "TestDerpVerify",
58 | RegionID: 900,
59 | HostName: derper.GetHostname(),
60 | STUNPort: derper.GetSTUNPort(),
61 | STUNOnly: false,
62 | DERPPort: derper.GetDERPPort(),
63 | },
64 | },
65 | },
66 | },
67 | }
68 |
69 | err = scenario.CreateHeadscaleEnv([]tsic.Option{tsic.WithCACert(derper.GetCert())},
70 | hsic.WithHostname(hostname),
71 | hsic.WithPort(headscalePort),
72 | hsic.WithCustomTLS(certHeadscale, keyHeadscale),
73 | hsic.WithDERPConfig(derpMap))
74 | assertNoErrHeadscaleEnv(t, err)
75 |
76 | allClients, err := scenario.ListTailscaleClients()
77 | assertNoErrListClients(t, err)
78 |
79 | for _, client := range allClients {
80 | report, err := client.DebugDERPRegion("test-derpverify")
81 | assertNoErr(t, err)
82 | successful := false
83 | for _, line := range report.Info {
84 | if strings.Contains(line, "Successfully established a DERP connection with node") {
85 | successful = true
86 |
87 | break
88 | }
89 | }
90 | if !successful {
91 | stJSON, err := json.Marshal(report)
92 | assertNoErr(t, err)
93 | t.Errorf("Client %s could not establish a DERP connection: %s", client.Hostname(), string(stJSON))
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/integration/dockertestutil/execute.go:
--------------------------------------------------------------------------------
1 | package dockertestutil
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "sync"
8 | "time"
9 |
10 | "github.com/ory/dockertest/v3"
11 | )
12 |
13 | const dockerExecuteTimeout = time.Second * 30
14 |
15 | var (
16 | ErrDockertestCommandFailed = errors.New("dockertest command failed")
17 | ErrDockertestCommandTimeout = errors.New("dockertest command timed out")
18 | )
19 |
20 | type ExecuteCommandConfig struct {
21 | timeout time.Duration
22 | }
23 |
24 | type ExecuteCommandOption func(*ExecuteCommandConfig) error
25 |
26 | func ExecuteCommandTimeout(timeout time.Duration) ExecuteCommandOption {
27 | return ExecuteCommandOption(func(conf *ExecuteCommandConfig) error {
28 | conf.timeout = timeout
29 | return nil
30 | })
31 | }
32 |
33 | // buffer is a goroutine safe bytes.buffer
34 | type buffer struct {
35 | store bytes.Buffer
36 | mutex sync.Mutex
37 | }
38 |
39 | // Write appends the contents of p to the buffer, growing the buffer as needed. It returns
40 | // the number of bytes written.
41 | func (b *buffer) Write(p []byte) (n int, err error) {
42 | b.mutex.Lock()
43 | defer b.mutex.Unlock()
44 | return b.store.Write(p)
45 | }
46 |
47 | // String returns the contents of the unread portion of the buffer
48 | // as a string.
49 | func (b *buffer) String() string {
50 | b.mutex.Lock()
51 | defer b.mutex.Unlock()
52 | return b.store.String()
53 | }
54 |
55 | func ExecuteCommand(
56 | resource *dockertest.Resource,
57 | cmd []string,
58 | env []string,
59 | options ...ExecuteCommandOption,
60 | ) (string, string, error) {
61 | var stdout = buffer{}
62 | var stderr = buffer{}
63 |
64 | execConfig := ExecuteCommandConfig{
65 | timeout: dockerExecuteTimeout,
66 | }
67 |
68 | for _, opt := range options {
69 | if err := opt(&execConfig); err != nil {
70 | return "", "", fmt.Errorf("execute-command/options: %w", err)
71 | }
72 | }
73 |
74 | type result struct {
75 | exitCode int
76 | err error
77 | }
78 |
79 | resultChan := make(chan result, 1)
80 |
81 | // Run your long running function in it's own goroutine and pass back it's
82 | // response into our channel.
83 | go func() {
84 | exitCode, err := resource.Exec(
85 | cmd,
86 | dockertest.ExecOptions{
87 | Env: append(env, "HEADSCALE_LOG_LEVEL=info"),
88 | StdOut: &stdout,
89 | StdErr: &stderr,
90 | },
91 | )
92 |
93 | resultChan <- result{exitCode, err}
94 | }()
95 |
96 | // Listen on our channel AND a timeout channel - which ever happens first.
97 | select {
98 | case res := <-resultChan:
99 | if res.err != nil {
100 | return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), res.err)
101 | }
102 |
103 | if res.exitCode != 0 {
104 | // Uncomment for debugging
105 | // log.Println("Command: ", cmd)
106 | // log.Println("stdout: ", stdout.String())
107 | // log.Println("stderr: ", stderr.String())
108 |
109 | return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), ErrDockertestCommandFailed)
110 | }
111 |
112 | return stdout.String(), stderr.String(), nil
113 | case <-time.After(execConfig.timeout):
114 | return stdout.String(), stderr.String(), fmt.Errorf("command failed, stderr: %s: %w", stderr.String(), ErrDockertestCommandTimeout)
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/integration/dockertestutil/network.go:
--------------------------------------------------------------------------------
1 | package dockertestutil
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "net"
8 |
9 | "github.com/juanfont/headscale/hscontrol/util"
10 | "github.com/ory/dockertest/v3"
11 | "github.com/ory/dockertest/v3/docker"
12 | )
13 |
14 | var ErrContainerNotFound = errors.New("container not found")
15 |
16 | func GetFirstOrCreateNetwork(pool *dockertest.Pool, name string) (*dockertest.Network, error) {
17 | networks, err := pool.NetworksByName(name)
18 | if err != nil {
19 | return nil, fmt.Errorf("looking up network names: %w", err)
20 | }
21 | if len(networks) == 0 {
22 | if _, err := pool.CreateNetwork(name); err == nil {
23 | // Create does not give us an updated version of the resource, so we need to
24 | // get it again.
25 | networks, err := pool.NetworksByName(name)
26 | if err != nil {
27 | return nil, err
28 | }
29 |
30 | return &networks[0], nil
31 | } else {
32 | return nil, fmt.Errorf("creating network: %w", err)
33 | }
34 | }
35 |
36 | return &networks[0], nil
37 | }
38 |
39 | func AddContainerToNetwork(
40 | pool *dockertest.Pool,
41 | network *dockertest.Network,
42 | testContainer string,
43 | ) error {
44 | containers, err := pool.Client.ListContainers(docker.ListContainersOptions{
45 | All: true,
46 | Filters: map[string][]string{
47 | "name": {testContainer},
48 | },
49 | })
50 | if err != nil {
51 | return err
52 | }
53 |
54 | err = pool.Client.ConnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
55 | Container: containers[0].ID,
56 | })
57 | if err != nil {
58 | return err
59 | }
60 |
61 | // TODO(kradalby): This doesn't work reliably, but calling the exact same functions
62 | // seem to work fine...
63 | // if container, ok := pool.ContainerByName("/" + testContainer); ok {
64 | // err := container.ConnectToNetwork(network)
65 | // if err != nil {
66 | // return err
67 | // }
68 | // }
69 |
70 | return nil
71 | }
72 |
73 | // RandomFreeHostPort asks the kernel for a free open port that is ready to use.
74 | // (from https://github.com/phayes/freeport)
75 | func RandomFreeHostPort() (int, error) {
76 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
77 | if err != nil {
78 | return 0, err
79 | }
80 |
81 | listener, err := net.ListenTCP("tcp", addr)
82 | if err != nil {
83 | return 0, err
84 | }
85 | defer listener.Close()
86 | //nolint:forcetypeassert
87 | return listener.Addr().(*net.TCPAddr).Port, nil
88 | }
89 |
90 | // CleanUnreferencedNetworks removes networks that are not referenced by any containers.
91 | func CleanUnreferencedNetworks(pool *dockertest.Pool) error {
92 | filter := "name=hs-"
93 | networks, err := pool.NetworksByName(filter)
94 | if err != nil {
95 | return fmt.Errorf("getting networks by filter %q: %w", filter, err)
96 | }
97 |
98 | for _, network := range networks {
99 | if network.Network.Containers == nil || len(network.Network.Containers) == 0 {
100 | err := pool.RemoveNetwork(&network)
101 | if err != nil {
102 | log.Printf("removing network %s: %s", network.Network.Name, err)
103 | }
104 | }
105 | }
106 |
107 | return nil
108 | }
109 |
110 | // CleanImagesInCI removes images if running in CI.
111 | func CleanImagesInCI(pool *dockertest.Pool) error {
112 | if !util.IsCI() {
113 | log.Println("Skipping image cleanup outside of CI")
114 | return nil
115 | }
116 |
117 | images, err := pool.Client.ListImages(docker.ListImagesOptions{})
118 | if err != nil {
119 | return fmt.Errorf("getting images: %w", err)
120 | }
121 |
122 | for _, image := range images {
123 | log.Printf("removing image: %s, %v", image.ID, image.RepoTags)
124 | _ = pool.Client.RemoveImage(image.ID)
125 | }
126 |
127 | return nil
128 | }
129 |
--------------------------------------------------------------------------------
/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_OVERRIDE_LOCAL_DNS": "false",
27 | "HEADSCALE_DNS_NAMESERVERS_GLOBAL": "127.0.0.11 1.1.1.1",
28 | "HEADSCALE_PRIVATE_KEY_PATH": "/tmp/private.key",
29 | "HEADSCALE_NOISE_PRIVATE_KEY_PATH": "/tmp/noise_private.key",
30 | "HEADSCALE_METRICS_LISTEN_ADDR": "0.0.0.0:9090",
31 | "HEADSCALE_DERP_URLS": "https://controlplane.tailscale.com/derpmap/default",
32 | "HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false",
33 | "HEADSCALE_DERP_UPDATE_FREQUENCY": "1m",
34 |
35 | // a bunch of tests (ACL/Policy) rely on predictable IP alloc,
36 | // so ensure the sequential alloc is used by default.
37 | "HEADSCALE_PREFIXES_ALLOCATION": string(types.IPAllocationStrategySequential),
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/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 "
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 |
--------------------------------------------------------------------------------
/integration/tailscale.go:
--------------------------------------------------------------------------------
1 | package integration
2 |
3 | import (
4 | "io"
5 | "net/netip"
6 | "net/url"
7 |
8 | "github.com/juanfont/headscale/hscontrol/types"
9 | "github.com/juanfont/headscale/hscontrol/util"
10 | "github.com/juanfont/headscale/integration/dockertestutil"
11 | "github.com/juanfont/headscale/integration/tsic"
12 | "tailscale.com/ipn/ipnstate"
13 | "tailscale.com/net/netcheck"
14 | "tailscale.com/types/netmap"
15 | )
16 |
17 | // nolint
18 | type TailscaleClient interface {
19 | Hostname() string
20 | Shutdown() (string, string, error)
21 | Version() string
22 | Execute(
23 | command []string,
24 | options ...dockertestutil.ExecuteCommandOption,
25 | ) (string, string, error)
26 | Login(loginServer, authKey string) error
27 | LoginWithURL(loginServer string) (*url.URL, error)
28 | Logout() error
29 | Up() error
30 | Down() error
31 | IPs() ([]netip.Addr, error)
32 | MustIPs() []netip.Addr
33 | MustIPv4() netip.Addr
34 | MustIPv6() netip.Addr
35 | FQDN() (string, error)
36 | Status(...bool) (*ipnstate.Status, error)
37 | MustStatus() *ipnstate.Status
38 | Netmap() (*netmap.NetworkMap, error)
39 | DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error)
40 | Netcheck() (*netcheck.Report, error)
41 | WaitForNeedsLogin() error
42 | WaitForRunning() error
43 | WaitForPeers(expected int) error
44 | Ping(hostnameOrIP string, opts ...tsic.PingOption) error
45 | Curl(url string, opts ...tsic.CurlOption) (string, error)
46 | Traceroute(netip.Addr) (util.Traceroute, error)
47 | ContainerID() string
48 | MustID() types.NodeID
49 | ReadFile(path string) ([]byte, error)
50 |
51 | // FailingPeersAsString returns a formatted-ish multi-line-string of peers in the client
52 | // and a bool indicating if the clients online count and peer count is equal.
53 | FailingPeersAsString() (string, bool, error)
54 |
55 | WriteLogs(stdout, stderr io.Writer) error
56 | }
57 |
--------------------------------------------------------------------------------
/packaging/README.md:
--------------------------------------------------------------------------------
1 | # Packaging
2 |
3 | We use [nFPM](https://nfpm.goreleaser.com/) for making `.deb` packages.
4 |
5 | This folder contains files we need to package with these releases.
6 |
--------------------------------------------------------------------------------
/packaging/deb/postinst:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # postinst script for headscale.
3 |
4 | set -e
5 |
6 | # Summary of how this script can be called:
7 | # * 'configure'
8 | # * 'abort-upgrade'
9 | # * 'abort-remove' 'in-favour'
10 | #
11 | # * 'abort-remove'
12 | # * 'abort-deconfigure' 'in-favour'
13 | # 'removing'
14 | #
15 | # for details, see https://www.debian.org/doc/debian-policy/ or
16 | # the debian-policy package.
17 |
18 | HEADSCALE_USER="headscale"
19 | HEADSCALE_GROUP="headscale"
20 | HEADSCALE_HOME_DIR="/var/lib/headscale"
21 | HEADSCALE_SHELL="/usr/sbin/nologin"
22 | HEADSCALE_SERVICE="headscale.service"
23 |
24 | case "$1" in
25 | configure)
26 | groupadd --force --system "$HEADSCALE_GROUP"
27 | if ! id -u "$HEADSCALE_USER" >/dev/null 2>&1; then
28 | useradd --system --shell "$HEADSCALE_SHELL" \
29 | --gid "$HEADSCALE_GROUP" --home-dir "$HEADSCALE_HOME_DIR" \
30 | --comment "headscale default user" "$HEADSCALE_USER"
31 | fi
32 |
33 | if dpkg --compare-versions "$2" lt-nl "0.27"; then
34 | # < 0.24.0-beta.1 used /home/headscale as home and /bin/sh as shell.
35 | # The directory /home/headscale was not created by the package or
36 | # useradd but the service always used /var/lib/headscale which was
37 | # always shipped by the package as empty directory. Previous versions
38 | # of the package did not update the user account properties.
39 | usermod --home "$HEADSCALE_HOME_DIR" --shell "$HEADSCALE_SHELL" \
40 | "$HEADSCALE_USER" >/dev/null
41 | fi
42 |
43 | if dpkg --compare-versions "$2" lt-nl "0.27" \
44 | && [ $(id --user "$HEADSCALE_USER") -ge 1000 ] \
45 | && [ $(id --group "$HEADSCALE_GROUP") -ge 1000 ]; then
46 | # < 0.26.0-beta.1 created a regular user/group to run headscale.
47 | # Previous versions of the package did not migrate to system uid/gid.
48 | # Assume that the *default* uid/gid range is in use and only run this
49 | # migration when the current uid/gid is allocated in the user range.
50 | # Create a temporary system user/group to guarantee the allocation of a
51 | # uid/gid in the system range. Assign this new uid/gid to the existing
52 | # user and group and remove the temporary user/group afterwards.
53 | tmp_name="headscaletmp"
54 | useradd --system --no-log-init --no-create-home --shell "$HEADSCALE_SHELL" "$tmp_name"
55 | tmp_uid="$(id --user "$tmp_name")"
56 | tmp_gid="$(id --group "$tmp_name")"
57 | usermod --non-unique --uid "$tmp_uid" --gid "$tmp_gid" "$HEADSCALE_USER"
58 | groupmod --non-unique --gid "$tmp_gid" "$HEADSCALE_USER"
59 | userdel --force "$tmp_name"
60 | fi
61 |
62 | # Enable service and keep track of its state
63 | if deb-systemd-helper --quiet was-enabled "$HEADSCALE_SERVICE"; then
64 | deb-systemd-helper enable "$HEADSCALE_SERVICE" >/dev/null || true
65 | else
66 | deb-systemd-helper update-state "$HEADSCALE_SERVICE" >/dev/null || true
67 | fi
68 |
69 | # Bounce service
70 | if [ -d /run/systemd/system ]; then
71 | systemctl --system daemon-reload >/dev/null || true
72 | if [ -n "$2" ]; then
73 | deb-systemd-invoke restart "$HEADSCALE_SERVICE" >/dev/null || true
74 | else
75 | deb-systemd-invoke start "$HEADSCALE_SERVICE" >/dev/null || true
76 | fi
77 | fi
78 | ;;
79 |
80 | abort-upgrade|abort-remove|abort-deconfigure)
81 | ;;
82 |
83 | *)
84 | echo "postinst called with unknown argument '$1'" >&2
85 | exit 1
86 | ;;
87 | esac
88 |
--------------------------------------------------------------------------------
/packaging/deb/postrm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # postrm script for headscale.
3 |
4 | set -e
5 |
6 | # Summary of how this script can be called:
7 | # * 'remove'
8 | # * 'purge'
9 | # * 'upgrade'
10 | # * 'failed-upgrade'
11 | # * 'abort-install'
12 | # * 'abort-install'
13 | # * 'abort-upgrade'
14 | # * 'disappear'
15 | #
16 | # for details, see https://www.debian.org/doc/debian-policy/ or
17 | # the debian-policy package.
18 |
19 |
20 | case "$1" in
21 | remove)
22 | if [ -d /run/systemd/system ]; then
23 | systemctl --system daemon-reload >/dev/null || true
24 | fi
25 | ;;
26 |
27 | purge)
28 | userdel headscale
29 | rm -rf /var/lib/headscale
30 | if [ -x "/usr/bin/deb-systemd-helper" ]; then
31 | deb-systemd-helper purge headscale.service >/dev/null || true
32 | fi
33 | ;;
34 |
35 | upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
36 | ;;
37 |
38 | *)
39 | echo "postrm called with unknown argument '$1'" >&2
40 | exit 1
41 | ;;
42 | esac
43 |
--------------------------------------------------------------------------------
/packaging/deb/prerm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # prerm script for headscale.
3 |
4 | set -e
5 |
6 | # Summary of how this script can be called:
7 | # * 'remove'
8 | # * 'upgrade'
9 | # * 'failed-upgrade'
10 | # * 'remove' 'in-favour'
11 | # * 'deconfigure' 'in-favour'
12 | # 'removing'
13 | #
14 | # for details, see https://www.debian.org/doc/debian-policy/ or
15 | # the debian-policy package.
16 |
17 |
18 | case "$1" in
19 | remove)
20 | if [ -d /run/systemd/system ]; then
21 | deb-systemd-invoke stop headscale.service >/dev/null || true
22 | fi
23 | ;;
24 | upgrade|deconfigure)
25 | ;;
26 |
27 | failed-upgrade)
28 | ;;
29 |
30 | *)
31 | echo "prerm called with unknown argument '$1'" >&2
32 | exit 1
33 | ;;
34 | esac
35 |
--------------------------------------------------------------------------------
/packaging/systemd/headscale.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | After=network.target
3 | Description=headscale coordination server for Tailscale
4 | X-Restart-Triggers=/etc/headscale/config.yaml
5 |
6 | [Service]
7 | Type=simple
8 | User=headscale
9 | Group=headscale
10 | ExecStart=/usr/bin/headscale serve
11 | ExecReload=/usr/bin/kill -HUP $MAINPID
12 | Restart=always
13 | RestartSec=5
14 |
15 | WorkingDirectory=/var/lib/headscale
16 | ReadWritePaths=/var/lib/headscale
17 |
18 | AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_CHOWN
19 | CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN
20 | LockPersonality=true
21 | NoNewPrivileges=true
22 | PrivateDevices=true
23 | PrivateMounts=true
24 | PrivateTmp=true
25 | ProcSubset=pid
26 | ProtectClock=true
27 | ProtectControlGroups=true
28 | ProtectHome=true
29 | ProtectHostname=true
30 | ProtectKernelLogs=true
31 | ProtectKernelModules=true
32 | ProtectKernelTunables=true
33 | ProtectProc=invisible
34 | ProtectSystem=strict
35 | RemoveIPC=true
36 | RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
37 | RestrictNamespaces=true
38 | RestrictRealtime=true
39 | RestrictSUIDSGID=true
40 | RuntimeDirectory=headscale
41 | RuntimeDirectoryMode=0750
42 | StateDirectory=headscale
43 | StateDirectoryMode=0750
44 | SystemCallArchitectures=native
45 | SystemCallFilter=@chown
46 | SystemCallFilter=@system-service
47 | SystemCallFilter=~@privileged
48 | UMask=0077
49 |
50 | [Install]
51 | WantedBy=multi-user.target
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 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 |
--------------------------------------------------------------------------------
/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 | repeated string approved_routes = 23;
52 | repeated string available_routes = 24;
53 | repeated string subnet_routes = 25;
54 | }
55 |
56 | message RegisterNodeRequest {
57 | string user = 1;
58 | string key = 2;
59 | }
60 |
61 | message RegisterNodeResponse { Node node = 1; }
62 |
63 | message GetNodeRequest { uint64 node_id = 1; }
64 |
65 | message GetNodeResponse { Node node = 1; }
66 |
67 | message SetTagsRequest {
68 | uint64 node_id = 1;
69 | repeated string tags = 2;
70 | }
71 |
72 | message SetTagsResponse { Node node = 1; }
73 |
74 | message SetApprovedRoutesRequest {
75 | uint64 node_id = 1;
76 | repeated string routes = 2;
77 | }
78 |
79 | message SetApprovedRoutesResponse { Node node = 1; }
80 |
81 | message DeleteNodeRequest { uint64 node_id = 1; }
82 |
83 | message DeleteNodeResponse {}
84 |
85 | message ExpireNodeRequest { uint64 node_id = 1; }
86 |
87 | message ExpireNodeResponse { Node node = 1; }
88 |
89 | message RenameNodeRequest {
90 | uint64 node_id = 1;
91 | string new_name = 2;
92 | }
93 |
94 | message RenameNodeResponse { Node node = 1; }
95 |
96 | message ListNodesRequest { string user = 1; }
97 |
98 | message ListNodesResponse { repeated Node nodes = 1; }
99 |
100 | message MoveNodeRequest {
101 | uint64 node_id = 1;
102 | uint64 user = 2;
103 | }
104 |
105 | message MoveNodeResponse { Node node = 1; }
106 |
107 | message DebugCreateNodeRequest {
108 | string user = 1;
109 | string key = 2;
110 | string name = 3;
111 | repeated string routes = 4;
112 | }
113 |
114 | message DebugCreateNodeResponse { Node node = 1; }
115 |
116 | message BackfillNodeIPsRequest { bool confirmed = 1; }
117 |
118 | message BackfillNodeIPsResponse { repeated string changes = 1; }
119 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | import "headscale/v1/user.proto";
7 |
8 | message PreAuthKey {
9 | User user = 1;
10 | uint64 id = 2;
11 | string key = 3;
12 | bool reusable = 4;
13 | bool ephemeral = 5;
14 | bool used = 6;
15 | google.protobuf.Timestamp expiration = 7;
16 | google.protobuf.Timestamp created_at = 8;
17 | repeated string acl_tags = 9;
18 | }
19 |
20 | message CreatePreAuthKeyRequest {
21 | uint64 user = 1;
22 | bool reusable = 2;
23 | bool ephemeral = 3;
24 | google.protobuf.Timestamp expiration = 4;
25 | repeated string acl_tags = 5;
26 | }
27 |
28 | message CreatePreAuthKeyResponse { PreAuthKey pre_auth_key = 1; }
29 |
30 | message ExpirePreAuthKeyRequest {
31 | uint64 user = 1;
32 | string key = 2;
33 | }
34 |
35 | message ExpirePreAuthKeyResponse {}
36 |
37 | message ListPreAuthKeysRequest { uint64 user = 1; }
38 |
39 | message ListPreAuthKeysResponse { repeated PreAuthKey pre_auth_keys = 1; }
40 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
48 |
49 | `))
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 |
--------------------------------------------------------------------------------