├── .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] <title>" 4 | labels: [enhancement] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Use case 9 | description: Please describe the use case for this feature. 10 | placeholder: | 11 | <!-- Include the reason, why you would need the feature. E.g. what problem 12 | does it solve? Or which workflow is currently frustrating and will be improved by 13 | this? --> 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Description 19 | description: A clear and precise description of what new or changed feature you want. 20 | validations: 21 | required: true 22 | - type: checkboxes 23 | attributes: 24 | label: Contribution 25 | description: Are you willing to contribute to the implementation of this feature? 26 | options: 27 | - label: I can write the design doc for this feature 28 | required: false 29 | - label: I can contribute this feature 30 | required: false 31 | - type: textarea 32 | attributes: 33 | label: How can it be implemented? 34 | description: Free text for your ideas on how this feature could be implemented. 35 | validations: 36 | required: false 37 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | <!-- 2 | Headscale is "Open Source, acknowledged contribution", this means that any 3 | contribution will have to be discussed with the Maintainers before being submitted. 4 | 5 | This model has been chosen to reduce the risk of burnout by limiting the 6 | maintenance overhead of reviewing and validating third-party code. 7 | 8 | Headscale is open to code contributions for bug fixes without discussion. 9 | 10 | If you find mistakes in the documentation, please submit a fix to the documentation. 11 | --> 12 | 13 | <!-- Please tick if the following things apply. You… --> 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 | <!-- If applicable, please reference the issue using `Fixes #XXX` and add tests to cover your new code. --> 23 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseBranches": ["main"], 3 | "username": "renovate-release", 4 | "gitAuthor": "Renovate Bot <bot@renovateapp.com>", 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*\"?(?<currentValue>.*?)\"?\\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 | <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 1280 640"><circle cx="141.023" cy="338.36" r="117.472" style="fill:#f8b5cb" transform="matrix(.997276 0 0 1.00556 10.0024 -14.823)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 -3.15847 0)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 -3.15847 115.914)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 148.43 115.914)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(1.01749 0 0 1 148.851 0)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.99196 0 0 1 3.36978 -10.2458)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.99196 0 0 1 255.633 -10.2458)"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030" transform="matrix(-1 0 0 1 1857.19 0)"/></svg> -------------------------------------------------------------------------------- /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: <https://github.com/juanfont/headscale/blob/main/config-example.yaml> 21 | * Version {{ headscale.version }}: <https://github.com/juanfont/headscale/blob/v{{ headscale.version }}/config-example.yaml> 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 | [![Packaging status](https://repology.org/badge/vertical-allrepos/headscale.svg)](https://repology.org/project/headscale/versions) 17 | 18 | ## Arch Linux 19 | 20 | Arch Linux offers a package for headscale, install via: 21 | 22 | ```shell 23 | pacman -S headscale 24 | ``` 25 | 26 | The [AUR package `headscale-git`](https://aur.archlinux.org/packages/headscale-git) can be used to build the current 27 | development version. 28 | 29 | ## Fedora, RHEL, CentOS 30 | 31 | A third-party repository for various RPM based distributions is available at: 32 | <https://copr.fedorainfracloud.org/coprs/jonathanspw/headscale/>. The site provides detailed setup and installation 33 | instructions. 34 | 35 | ## Nix, NixOS 36 | 37 | A Nix package is available as: `headscale`. See the [NixOS package site for installation 38 | details](https://search.nixos.org/packages?show=headscale). 39 | 40 | ## Gentoo 41 | 42 | ```shell 43 | emerge --ask net-vpn/headscale 44 | ``` 45 | 46 | Gentoo specific documentation is available [here](https://wiki.gentoo.org/wiki/User:Maffblaster/Drafts/Headscale). 47 | 48 | ## OpenBSD 49 | 50 | Headscale is available in ports. The port installs headscale as system service with `rc.d` and provides usage 51 | instructions upon installation. 52 | 53 | ```shell 54 | pkg_add headscale 55 | ``` 56 | -------------------------------------------------------------------------------- /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 `<VALUE_TO_CHANGE>` or use placeholder 21 | values such as `headscale.example.com`. 22 | 23 | Please adjust to your local environment accordingly. 24 | 25 | [^1]: 26 | The Tailscale client assumes HTTPS on port 443 in certain situations. Serving headscale either via HTTP or via HTTPS 27 | on a port other than 443 is possible but sticking with HTTPS on port 443 is strongly recommended for production 28 | setups. See [issue 2164](https://github.com/juanfont/headscale/issues/2164) for more information. 29 | -------------------------------------------------------------------------------- /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 <YOUR_HEADSCALE_URL> 38 | ``` 39 | 40 | #### GUI 41 | 42 | - Option + Click the Tailscale icon in the menu and hover over the Debug menu 43 | - Under `Custom Login Server`, select `Add Account...` 44 | - Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `Add Account` 45 | - Follow the login procedure in the browser 46 | 47 | ## tvOS 48 | 49 | ### Installation 50 | 51 | Install the official Tailscale tvOS client from the [App Store](https://apps.apple.com/app/tailscale/id1470499037). 52 | 53 | !!! danger 54 | 55 | **Don't** open the Tailscale App after installation! 56 | 57 | ### Configuring the headscale URL 58 | 59 | - Open Settings (the Apple tvOS settings) > Apps > Tailscale 60 | - Under `ALTERNATE COORDINATION SERVER URL`, select `URL` 61 | - Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `OK` 62 | - Return to the tvOS Home screen 63 | - Open Tailscale 64 | - Click the button `Install VPN configuration` and confirm the appearing popup by clicking the `Allow` button 65 | - Scan the QR code and follow the login procedure 66 | -------------------------------------------------------------------------------- /docs/usage/connect/windows.md: -------------------------------------------------------------------------------- 1 | # Connecting a Windows client 2 | 3 | This documentation has the goal of showing how a user can use the official Windows [Tailscale](https://tailscale.com) client with headscale. 4 | 5 | !!! info "Instructions on your headscale instance" 6 | 7 | An endpoint with information on how to connect your Windows device 8 | is also available at `/windows` on your running instance. 9 | 10 | ## Installation 11 | 12 | Download the [Official Windows Client](https://tailscale.com/download/windows) and install it. 13 | 14 | ## Configuring the headscale URL 15 | 16 | Open a Command Prompt or Powershell and use Tailscale's login command to connect with your headscale instance (e.g 17 | `https://headscale.example.com`): 18 | 19 | ``` 20 | tailscale login --login-server <YOUR_HEADSCALE_URL> 21 | ``` 22 | 23 | Follow the instructions in the opened browser window to finish the configuration. 24 | 25 | ## Troubleshooting 26 | 27 | ### Unattended mode 28 | 29 | By default, Tailscale's Windows client is only running when the user is logged in. If you want to keep Tailscale running 30 | all the time, please enable "Unattended mode": 31 | 32 | - Click on the Tailscale tray icon and select `Preferences` 33 | - Enable `Run unattended` 34 | - Confirm the "Unattended mode" message 35 | 36 | See also [Keep Tailscale running when I'm not logged in to my computer](https://tailscale.com/kb/1088/run-unattended) 37 | 38 | ### Failing node registration 39 | 40 | If you are seeing repeated messages like: 41 | 42 | ``` 43 | [GIN] 2022/02/10 - 16:39:34 | 200 | 1.105306ms | 127.0.0.1 | POST "/machine/redacted" 44 | ``` 45 | 46 | in your headscale output, turn on `DEBUG` logging and look for: 47 | 48 | ``` 49 | 2022-02-11T00:59:29Z DBG Machine registration has expired. Sending a authurl to register machine=redacted 50 | ``` 51 | 52 | This typically means that the registry keys above was not set appropriately. 53 | 54 | To reset and try again, it is important to do the following: 55 | 56 | 1. Shut down the Tailscale service (or the client running in the tray) 57 | 2. Delete Tailscale Application data folder, located at `C:\Users\<USERNAME>\AppData\Local\Tailscale` and try to connect again. 58 | 3. Ensure the Windows node is deleted from headscale (to ensure fresh setup) 59 | 4. Start Tailscale on the Windows machine and retry the login. 60 | -------------------------------------------------------------------------------- /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 <test_name> <num_tests>" 62 | exit 1 63 | fi 64 | 65 | test_name=$1 66 | num_tests=$2 67 | 68 | docker network prune -f 69 | 70 | if [ "$test_name" = "all" ]; then 71 | rg --regexp "func (Test.+)\(.*" ./integration/ --replace '$1' --no-line-number --no-filename --no-heading | sort | while read -r test_name; do 72 | run_tests "$test_name" "$num_tests" 73 | done 74 | else 75 | run_tests "$test_name" "$num_tests" 76 | fi 77 | -------------------------------------------------------------------------------- /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 | # * <postinst> 'configure' <most-recently-configured-version> 8 | # * <old-postinst> 'abort-upgrade' <new version> 9 | # * <conflictor's-postinst> 'abort-remove' 'in-favour' <package> 10 | # <new-version> 11 | # * <postinst> 'abort-remove' 12 | # * <deconfigured's-postinst> 'abort-deconfigure' 'in-favour' 13 | # <failed-install-package> <version> 'removing' 14 | # <conflicting-package> <version> 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 | # * <postrm> 'remove' 8 | # * <postrm> 'purge' 9 | # * <old-postrm> 'upgrade' <new-version> 10 | # * <new-postrm> 'failed-upgrade' <old-version> 11 | # * <new-postrm> 'abort-install' 12 | # * <new-postrm> 'abort-install' <old-version> 13 | # * <new-postrm> 'abort-upgrade' <old-version> 14 | # * <disappearer's-postrm> 'disappear' <overwriter> 15 | # <overwriter-version> 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 | # * <prerm> 'remove' 8 | # * <old-prerm> 'upgrade' <new-version> 9 | # * <new-prerm> 'failed-upgrade' <old-version> 10 | # * <conflictor's-prerm> 'remove' 'in-favour' <package> <new-version> 11 | # * <deconfigured's-prerm> 'deconfigure' 'in-favour' 12 | # <package-being-installed> <version> 'removing' 13 | # <conflicting-package> <version> 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<string, Latency> latency = 4; 29 | ClientSupports client_supports = 5; 30 | } 31 | 32 | message GetDeviceRequest { string id = 1; } 33 | 34 | message GetDeviceResponse { 35 | repeated string addresses = 1; 36 | string id = 2; 37 | string user = 3; 38 | string name = 4; 39 | string hostname = 5; 40 | string client_version = 6; 41 | bool update_available = 7; 42 | string os = 8; 43 | google.protobuf.Timestamp created = 9; 44 | google.protobuf.Timestamp last_seen = 10; 45 | bool key_expiry_disabled = 11; 46 | google.protobuf.Timestamp expires = 12; 47 | bool authorized = 13; 48 | bool is_external = 14; 49 | string machine_key = 15; 50 | string node_key = 16; 51 | bool blocks_incoming_connections = 17; 52 | repeated string enabled_routes = 18; 53 | repeated string advertised_routes = 19; 54 | ClientConnectivity client_connectivity = 20; 55 | } 56 | 57 | message DeleteDeviceRequest { string id = 1; } 58 | 59 | message DeleteDeviceResponse {} 60 | 61 | message GetDeviceRoutesRequest { string id = 1; } 62 | 63 | message GetDeviceRoutesResponse { 64 | repeated string enabled_routes = 1; 65 | repeated string advertised_routes = 2; 66 | } 67 | 68 | message EnableDeviceRoutesRequest { 69 | string id = 1; 70 | repeated string routes = 2; 71 | } 72 | 73 | message EnableDeviceRoutesResponse { 74 | repeated string enabled_routes = 1; 75 | repeated string advertised_routes = 2; 76 | } 77 | -------------------------------------------------------------------------------- /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 | <html> 21 | <head> 22 | <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"> 23 | 24 | <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-standalone-preset.js"></script> 25 | <script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js" charset="UTF-8"></script> 26 | </head> 27 | <body> 28 | <div id="swagger-ui"></div> 29 | <script> 30 | window.addEventListener('load', (event) => { 31 | const ui = SwaggerUIBundle({ 32 | url: "/swagger/v1/openapiv2.json", 33 | dom_id: '#swagger-ui', 34 | presets: [ 35 | SwaggerUIBundle.presets.apis, 36 | SwaggerUIBundle.SwaggerUIStandalonePreset 37 | ], 38 | plugins: [ 39 | SwaggerUIBundle.plugins.DownloadUrl 40 | ], 41 | deepLinking: true, 42 | // TODO(kradalby): Figure out why this does not work 43 | // layout: "StandaloneLayout", 44 | }) 45 | window.ui = ui 46 | }); 47 | </script> 48 | </body> 49 | </html>`)) 50 | 51 | var payload bytes.Buffer 52 | if err := swaggerTemplate.Execute(&payload, struct{}{}); err != nil { 53 | log.Error(). 54 | Caller(). 55 | Err(err). 56 | Msg("Could not render Swagger") 57 | 58 | writer.Header().Set("Content-Type", "text/plain; charset=utf-8") 59 | writer.WriteHeader(http.StatusInternalServerError) 60 | _, err := writer.Write([]byte("Could not render Swagger")) 61 | if err != nil { 62 | log.Error(). 63 | Caller(). 64 | Err(err). 65 | Msg("Failed to write response") 66 | } 67 | 68 | return 69 | } 70 | 71 | writer.Header().Set("Content-Type", "text/html; charset=utf-8") 72 | writer.WriteHeader(http.StatusOK) 73 | _, err := writer.Write(payload.Bytes()) 74 | if err != nil { 75 | log.Error(). 76 | Caller(). 77 | Err(err). 78 | Msg("Failed to write response") 79 | } 80 | } 81 | 82 | func SwaggerAPIv1( 83 | writer http.ResponseWriter, 84 | req *http.Request, 85 | ) { 86 | writer.Header().Set("Content-Type", "application/json; charset=utf-8") 87 | writer.WriteHeader(http.StatusOK) 88 | if _, err := writer.Write(apiV1JSON); err != nil { 89 | log.Error(). 90 | Caller(). 91 | Err(err). 92 | Msg("Failed to write response") 93 | } 94 | } 95 | --------------------------------------------------------------------------------