mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-15 05:19:55 +02:00
Compare commits
1 Commits
main
...
warn-again
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adf5111167 |
@@ -17,7 +17,3 @@ LICENSE
|
||||
.vscode
|
||||
|
||||
*.sock
|
||||
|
||||
node_modules/
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 120
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
16
.github/CODEOWNERS
vendored
16
.github/CODEOWNERS
vendored
@@ -1,10 +1,10 @@
|
||||
* @juanfont @kradalby
|
||||
|
||||
*.md @ohdearaugustin @nblock
|
||||
*.yml @ohdearaugustin @nblock
|
||||
*.yaml @ohdearaugustin @nblock
|
||||
Dockerfile* @ohdearaugustin @nblock
|
||||
.goreleaser.yaml @ohdearaugustin @nblock
|
||||
/docs/ @ohdearaugustin @nblock
|
||||
/.github/workflows/ @ohdearaugustin @nblock
|
||||
/.github/renovate.json @ohdearaugustin @nblock
|
||||
*.md @ohdearaugustin
|
||||
*.yml @ohdearaugustin
|
||||
*.yaml @ohdearaugustin
|
||||
Dockerfile* @ohdearaugustin
|
||||
.goreleaser.yaml @ohdearaugustin
|
||||
/docs/ @ohdearaugustin
|
||||
/.github/workflows/ @ohdearaugustin
|
||||
/.github/renovate.json @ohdearaugustin
|
||||
|
||||
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: "Bug report"
|
||||
about: "Create a bug report to help us improve"
|
||||
title: ""
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the bug report in this language. -->
|
||||
|
||||
**Bug description**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. Describe the expected bahavior
|
||||
and how it is currently different. If you are unsure if it is a bug, consider discussing
|
||||
it on our Discord server first. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior. -->
|
||||
|
||||
**Context info**
|
||||
|
||||
<!-- Please add relevant information about your system. For example:
|
||||
- Version of headscale used
|
||||
- Version of tailscale client
|
||||
- OS (e.g. Linux, Mac, Cygwin, WSL, etc.) and version
|
||||
- Kernel version
|
||||
- The relevant config parameters you used
|
||||
- Log output
|
||||
-->
|
||||
106
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
106
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -1,106 +0,0 @@
|
||||
name: 🐞 Bug
|
||||
description: File a bug/issue
|
||||
title: "[Bug] <title>"
|
||||
labels: ["bug", "needs triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is this a support request?
|
||||
description: This issue tracker is for bugs and feature requests only. If you need
|
||||
help, please use ask in our Discord community
|
||||
options:
|
||||
- label: This is not a support request
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you
|
||||
encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
1. With this config...
|
||||
1. Run '...'
|
||||
1. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
Please provide information about your environment.
|
||||
If you are using a container, always provide the headscale version and not only the Docker image version.
|
||||
Please do not put "latest".
|
||||
|
||||
Describe your "headscale network". Is there a lot of nodes, are the nodes all interconnected, are some subnet routers?
|
||||
|
||||
If you are experiencing a problem during an upgrade, please provide the versions of the old and new versions of Headscale and Tailscale.
|
||||
|
||||
examples:
|
||||
- **OS**: Ubuntu 24.04
|
||||
- **Headscale version**: 0.24.3
|
||||
- **Tailscale version**: 1.80.0
|
||||
- **Number of nodes**: 20
|
||||
value: |
|
||||
- OS:
|
||||
- Headscale version:
|
||||
- Tailscale version:
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Runtime environment
|
||||
options:
|
||||
- label: Headscale is behind a (reverse) proxy
|
||||
required: false
|
||||
- label: Headscale runs in a container
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Debug information
|
||||
description: |
|
||||
Please have a look at our [Debugging and troubleshooting
|
||||
guide](https://headscale.net/development/ref/debug/) to learn about
|
||||
common debugging techniques.
|
||||
|
||||
Links? References? Anything that will give us more context about the issue you are encountering.
|
||||
If **any** of these are omitted we will likely close your issue, do **not** ignore them.
|
||||
|
||||
- Client netmap dump (see below)
|
||||
- Policy configuration
|
||||
- Headscale configuration
|
||||
- Headscale log (with `trace` enabled)
|
||||
|
||||
Dump the netmap of tailscale clients:
|
||||
`tailscale debug netmap > DESCRIPTIVE_NAME.json`
|
||||
|
||||
Dump the status of tailscale clients:
|
||||
`tailscale status --json > DESCRIPTIVE_NAME.json`
|
||||
|
||||
Get the logs of a Tailscale client that is not working as expected.
|
||||
`tailscale debug daemon-logs`
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
**Ensure** you use formatting for files you attach.
|
||||
Do **not** paste in long files.
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,9 +3,9 @@ blank_issues_enabled: false
|
||||
|
||||
# Contact links
|
||||
contact_links:
|
||||
- name: "headscale Discord community"
|
||||
url: "https://discord.gg/c84AZQhmpx"
|
||||
about: "Please ask and answer questions about usage of headscale here."
|
||||
- name: "headscale usage documentation"
|
||||
url: "https://headscale.net/"
|
||||
url: "https://github.com/juanfont/headscale/blob/main/docs"
|
||||
about: "Find documentation about how to configure and run headscale."
|
||||
- name: "headscale Discord community"
|
||||
url: "https://discord.gg/xGj2TuqyxY"
|
||||
about: "Please ask and answer questions about usage of headscale here."
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: "Feature request"
|
||||
about: "Suggest an idea for headscale"
|
||||
title: ""
|
||||
labels: ["enhancement"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the feature request in this language. -->
|
||||
|
||||
**Feature request**
|
||||
|
||||
<!-- A clear and precise description of what new or changed feature you want. -->
|
||||
|
||||
<!-- Please include the reason, why you would need the feature. E.g. what problem
|
||||
does it solve? Or which workflow is currently frustrating and will be improved by
|
||||
this? -->
|
||||
36
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
36
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -1,36 +0,0 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Suggest an idea for Headscale
|
||||
title: "[Feature] <title>"
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Use case
|
||||
description: Please describe the use case for this feature.
|
||||
placeholder: |
|
||||
<!-- Include the reason, why you would need the feature. E.g. what problem
|
||||
does it solve? Or which workflow is currently frustrating and will be improved by
|
||||
this? -->
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and precise description of what new or changed feature you want.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Contribution
|
||||
description: Are you willing to contribute to the implementation of this feature?
|
||||
options:
|
||||
- label: I can write the design doc for this feature
|
||||
required: false
|
||||
- label: I can contribute this feature
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: How can it be implemented?
|
||||
description: Free text for your ideas on how this feature could be implemented.
|
||||
validations:
|
||||
required: false
|
||||
30
.github/ISSUE_TEMPLATE/other_issue.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/other_issue.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: "Other issue"
|
||||
about: "Report a different issue"
|
||||
title: ""
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- Headscale is a multinational community across the globe. Our common language is English. Please consider raising the issue in this language. -->
|
||||
|
||||
<!-- If you have a question, please consider using our Discord for asking questions -->
|
||||
|
||||
**Issue description**
|
||||
|
||||
<!-- Please add your issue description. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior. -->
|
||||
|
||||
**Context info**
|
||||
|
||||
<!-- Please add relevant information about your system. For example:
|
||||
- Version of headscale used
|
||||
- Version of tailscale client
|
||||
- OS (e.g. Linux, Mac, Cygwin, WSL, etc.) and version
|
||||
- Kernel version
|
||||
- The relevant config parameters you used
|
||||
- Log output
|
||||
-->
|
||||
80
.github/label-response/needs-more-info.md
vendored
80
.github/label-response/needs-more-info.md
vendored
@@ -1,80 +0,0 @@
|
||||
Thank you for taking the time to report this issue.
|
||||
|
||||
To help us investigate and resolve this, we need more information. Please provide the following:
|
||||
|
||||
> [!TIP]
|
||||
> Most issues turn out to be configuration errors rather than bugs. We encourage you to discuss your problem in our [Discord community](https://discord.gg/c84AZQhmpx) **before** opening an issue. The community can often help identify misconfigurations quickly, saving everyone time.
|
||||
|
||||
## Required Information
|
||||
|
||||
### Environment Details
|
||||
|
||||
- **Headscale version**: (run `headscale version`)
|
||||
- **Tailscale client version**: (run `tailscale version`)
|
||||
- **Operating System**: (e.g., Ubuntu 24.04, macOS 14, Windows 11)
|
||||
- **Deployment method**: (binary, Docker, Kubernetes, etc.)
|
||||
- **Reverse proxy**: (if applicable: nginx, Traefik, Caddy, etc. - include configuration)
|
||||
|
||||
### Debug Information
|
||||
|
||||
Please follow our [Debugging and Troubleshooting Guide](https://headscale.net/stable/ref/debug/) and provide:
|
||||
|
||||
1. **Client netmap dump** (from affected Tailscale client):
|
||||
|
||||
```bash
|
||||
tailscale debug netmap > netmap.json
|
||||
```
|
||||
|
||||
2. **Client status dump** (from affected Tailscale client):
|
||||
|
||||
```bash
|
||||
tailscale status --json > status.json
|
||||
```
|
||||
|
||||
3. **Tailscale client logs** (if experiencing client issues):
|
||||
|
||||
```bash
|
||||
tailscale debug daemon-logs
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We need logs from **multiple nodes** to understand the full picture:
|
||||
>
|
||||
> - The node(s) initiating connections
|
||||
> - The node(s) being connected to
|
||||
>
|
||||
> Without logs from both sides, we cannot diagnose connectivity issues.
|
||||
|
||||
4. **Headscale server logs** with `log.level: trace` enabled
|
||||
|
||||
5. **Headscale configuration** (with sensitive values redacted - see rules below)
|
||||
|
||||
6. **ACL/Policy configuration** (if using ACLs)
|
||||
|
||||
7. **Proxy/Docker configuration** (if applicable - nginx.conf, docker-compose.yml, Traefik config, etc.)
|
||||
|
||||
## Formatting Requirements
|
||||
|
||||
- **Attach long files** - Do not paste large logs or configurations inline. Use GitHub file attachments or GitHub Gists.
|
||||
- **Use proper Markdown** - Format code blocks, logs, and configurations with appropriate syntax highlighting.
|
||||
- **Structure your response** - Use the headings above to organize your information clearly.
|
||||
|
||||
## Redaction Rules
|
||||
|
||||
> [!CAUTION]
|
||||
> **Replace, do not remove.** Removing information makes debugging impossible.
|
||||
|
||||
When redacting sensitive information:
|
||||
|
||||
- ✅ **Replace consistently** - If you change `alice@company.com` to `user1@example.com`, use `user1@example.com` everywhere (logs, config, policy, etc.)
|
||||
- ✅ **Use meaningful placeholders** - `user1@example.com`, `bob@example.com`, `my-secret-key` are acceptable
|
||||
- ❌ **Never remove information** - Gaps in data prevent us from correlating events across logs
|
||||
- ❌ **Never redact IP addresses** - We need the actual IPs to trace network paths and identify issues
|
||||
|
||||
**If redaction rules are not followed, we will be unable to debug the issue and will have to close it.**
|
||||
|
||||
---
|
||||
|
||||
**Note:** This issue will be automatically closed in 3 days if no additional information is provided. Once you reply with the requested information, the `needs-more-info` label will be removed automatically.
|
||||
|
||||
If you need help gathering this information, please visit our [Discord community](https://discord.gg/c84AZQhmpx).
|
||||
15
.github/label-response/support-request.md
vendored
15
.github/label-response/support-request.md
vendored
@@ -1,15 +0,0 @@
|
||||
Thank you for reaching out.
|
||||
|
||||
This issue tracker is used for **bug reports and feature requests** only. Your question appears to be a support or configuration question rather than a bug report.
|
||||
|
||||
For help with setup, configuration, or general questions, please visit our [Discord community](https://discord.gg/c84AZQhmpx) where the community and maintainers can assist you in real-time.
|
||||
|
||||
**Before posting in Discord, please check:**
|
||||
|
||||
- [Documentation](https://headscale.net/)
|
||||
- [FAQ](https://headscale.net/stable/faq/)
|
||||
- [Debugging and Troubleshooting Guide](https://headscale.net/stable/ref/debug/)
|
||||
|
||||
If after troubleshooting you determine this is actually a bug, please open a new issue with the required debug information from the troubleshooting guide.
|
||||
|
||||
This issue has been automatically closed.
|
||||
14
.github/pull_request_template.md
vendored
14
.github/pull_request_template.md
vendored
@@ -1,18 +1,6 @@
|
||||
<!--
|
||||
Headscale is "Open Source, acknowledged contribution", this means that any
|
||||
contribution will have to be discussed with the Maintainers before being submitted.
|
||||
|
||||
This model has been chosen to reduce the risk of burnout by limiting the
|
||||
maintenance overhead of reviewing and validating third-party code.
|
||||
|
||||
Headscale is open to code contributions for bug fixes without discussion.
|
||||
|
||||
If you find mistakes in the documentation, please submit a fix to the documentation.
|
||||
-->
|
||||
|
||||
<!-- Please tick if the following things apply. You… -->
|
||||
|
||||
- [ ] have read the [CONTRIBUTING.md](./CONTRIBUTING.md) file
|
||||
- [ ] read the [CONTRIBUTING guidelines](README.md#contributing)
|
||||
- [ ] raised a GitHub issue or discussed it on the projects chat beforehand
|
||||
- [ ] added unit tests
|
||||
- [ ] added integration tests
|
||||
|
||||
26
.github/renovate.json
vendored
26
.github/renovate.json
vendored
@@ -6,27 +6,31 @@
|
||||
"onboarding": false,
|
||||
"extends": ["config:base", ":rebaseStalePrs"],
|
||||
"ignorePresets": [":prHourlyLimit2"],
|
||||
"enabledManagers": ["dockerfile", "gomod", "github-actions", "regex"],
|
||||
"enabledManagers": ["dockerfile", "gomod", "github-actions","regex" ],
|
||||
"includeForks": true,
|
||||
"repositories": ["juanfont/headscale"],
|
||||
"platform": "github",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDatasources": ["go"],
|
||||
"groupName": "Go modules",
|
||||
"groupSlug": "gomod",
|
||||
"separateMajorMinor": false
|
||||
"matchDatasources": ["go"],
|
||||
"groupName": "Go modules",
|
||||
"groupSlug": "gomod",
|
||||
"separateMajorMinor": false
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"groupName": "Dockerfiles",
|
||||
"groupSlug": "dockerfiles"
|
||||
}
|
||||
"matchDatasources": ["docker"],
|
||||
"groupName": "Dockerfiles",
|
||||
"groupSlug": "dockerfiles"
|
||||
}
|
||||
],
|
||||
"regexManagers": [
|
||||
{
|
||||
"fileMatch": [".github/workflows/.*.yml$"],
|
||||
"matchStrings": ["\\s*go-version:\\s*\"?(?<currentValue>.*?)\"?\\n"],
|
||||
"fileMatch": [
|
||||
".github/workflows/.*.yml$"
|
||||
],
|
||||
"matchStrings": [
|
||||
"\\s*go-version:\\s*\"?(?<currentValue>.*?)\"?\\n"
|
||||
],
|
||||
"datasourceTemplate": "golang-version",
|
||||
"depNameTemplate": "actions/go-version"
|
||||
}
|
||||
|
||||
98
.github/workflows/build.yml
vendored
98
.github/workflows/build.yml
vendored
@@ -5,96 +5,38 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-nix:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- name: Run nix build
|
||||
id: build
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
nix build |& tee build-result
|
||||
BUILD_STATUS="${PIPESTATUS[0]}"
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
OLD_HASH=$(cat build-result | grep specified: | awk -F ':' '{print $2}' | sed 's/ //g')
|
||||
NEW_HASH=$(cat build-result | grep got: | awk -F ':' '{print $2}' | sed 's/ //g')
|
||||
- name: Run build
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: nix build
|
||||
|
||||
echo "OLD_HASH=$OLD_HASH" >> $GITHUB_OUTPUT
|
||||
echo "NEW_HASH=$NEW_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
exit $BUILD_STATUS
|
||||
|
||||
- name: Nix gosum diverging
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
if: failure() && steps.build.outcome == 'failure'
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
github.rest.pulls.createReviewComment({
|
||||
pull_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
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 }}'
|
||||
})
|
||||
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
with:
|
||||
name: headscale-linux
|
||||
path: result/bin/headscale
|
||||
build-cross:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
env:
|
||||
- "GOARCH=arm64 GOOS=linux"
|
||||
- "GOARCH=amd64 GOOS=linux"
|
||||
- "GOARCH=arm64 GOOS=darwin"
|
||||
- "GOARCH=amd64 GOOS=darwin"
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Run go cross compile
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: env ${{ matrix.env }} nix develop --command -- go build -o "headscale"
|
||||
./cmd/headscale
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: "headscale-${{ matrix.env }}"
|
||||
path: "headscale"
|
||||
|
||||
55
.github/workflows/check-generated.yml
vendored
55
.github/workflows/check-generated.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Check Generated Files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-generated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- '**/*.proto'
|
||||
- 'buf.gen.yaml'
|
||||
- 'tools/**'
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Run make generate
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: nix develop --command -- make generate
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "❌ Generated files are not up to date!"
|
||||
echo "Please run 'make generate' and commit the changes."
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All generated files are up to date."
|
||||
fi
|
||||
45
.github/workflows/check-tests.yaml
vendored
45
.github/workflows/check-tests.yaml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Check integration tests workflow
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Generate and check integration tests
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
nix develop --command bash -c "cd .github/workflows && go generate"
|
||||
git diff --exit-code .github/workflows/test-integration.yaml
|
||||
|
||||
- name: Show missing tests
|
||||
if: failure()
|
||||
run: |
|
||||
git diff .github/workflows/test-integration.yaml
|
||||
112
.github/workflows/container-main.yml
vendored
112
.github/workflows/container-main.yml
vendored
@@ -1,112 +0,0 @@
|
||||
---
|
||||
name: Build (main)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "*.nix"
|
||||
- "go.*"
|
||||
- "**/*.go"
|
||||
- ".github/workflows/container-main.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
container:
|
||||
if: github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Set commit timestamp
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push to GHCR
|
||||
env:
|
||||
KO_DOCKER_REPO: ghcr.io/juanfont/headscale
|
||||
KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13
|
||||
CGO_ENABLED: "0"
|
||||
run: |
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
./cmd/headscale
|
||||
|
||||
- name: Push to Docker Hub
|
||||
env:
|
||||
KO_DOCKER_REPO: headscale/headscale
|
||||
KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13
|
||||
CGO_ENABLED: "0"
|
||||
run: |
|
||||
nix develop --command -- ko build \
|
||||
--bare \
|
||||
--platform=linux/amd64,linux/arm64 \
|
||||
--tags=main-${GITHUB_SHA::7} \
|
||||
./cmd/headscale
|
||||
|
||||
binaries:
|
||||
if: github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: nix develop --command -- go build -o headscale ./cmd/headscale
|
||||
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: headscale-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: headscale
|
||||
35
.github/workflows/contributors.yml
vendored
Normal file
35
.github/workflows/contributors.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Contributors
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
add-contributors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Delete upstream contributor branch
|
||||
# Allow continue on failure to account for when the
|
||||
# upstream branch is deleted or does not exist.
|
||||
continue-on-error: true
|
||||
run: git push origin --delete update-contributors
|
||||
- name: Create up-to-date contributors branch
|
||||
run: git checkout -B update-contributors
|
||||
- name: Push empty contributors branch
|
||||
run: git push origin update-contributors
|
||||
- name: Switch back to main
|
||||
run: git checkout main
|
||||
- uses: BobAnkh/add-contributors@v0.2.2
|
||||
with:
|
||||
CONTRIBUTOR: "## Contributors"
|
||||
COLUMN_PER_ROW: "6"
|
||||
ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
IMG_WIDTH: "100"
|
||||
FONT_SIZE: "14"
|
||||
PATH: "/README.md"
|
||||
COMMIT_MESSAGE: "docs(README): update contributors"
|
||||
AVATAR_SHAPE: "round"
|
||||
BRANCH: "update-contributors"
|
||||
PULL_REQUEST: "main"
|
||||
51
.github/workflows/docs-deploy.yml
vendored
51
.github/workflows/docs-deploy.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Deploy docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# Main branch for development docs
|
||||
- main
|
||||
|
||||
# Doc maintenance branches
|
||||
- doc/[0-9]+.[0-9]+.[0-9]+
|
||||
tags:
|
||||
# Stable release tags
|
||||
- v[0-9]+.[0-9]+.[0-9]+
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Setup cache
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
with:
|
||||
key: ${{ github.ref }}
|
||||
path: .cache
|
||||
- name: Setup dependencies
|
||||
run: pip install -r docs/requirements.txt
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
- name: Deploy development docs
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: mike deploy --push development unstable
|
||||
- name: Deploy stable docs from doc branches
|
||||
if: startsWith(github.ref, 'refs/heads/doc/')
|
||||
run: mike deploy --push ${GITHUB_REF_NAME##*/}
|
||||
- name: Deploy stable docs from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# This assumes that only newer tags are pushed
|
||||
run: mike deploy --push --update-aliases ${GITHUB_REF_NAME#v} stable latest
|
||||
27
.github/workflows/docs-test.yml
vendored
27
.github/workflows/docs-test.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Test documentation build
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Setup cache
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
|
||||
with:
|
||||
key: ${{ github.ref }}
|
||||
path: .cache
|
||||
- name: Setup dependencies
|
||||
run: pip install -r docs/requirements.txt
|
||||
- name: Build docs
|
||||
run: mkdocs build --strict
|
||||
144
.github/workflows/gh-action-integration-generator.go
vendored
144
.github/workflows/gh-action-integration-generator.go
vendored
@@ -1,144 +0,0 @@
|
||||
package main
|
||||
|
||||
//go:generate go run ./gh-action-integration-generator.go
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// testsToSplit defines tests that should be split into multiple CI jobs.
|
||||
// Key is the test function name, value is a list of subtest prefixes.
|
||||
// Each prefix becomes a separate CI job as "TestName/prefix".
|
||||
//
|
||||
// Example: TestAutoApproveMultiNetwork has subtests like:
|
||||
// - TestAutoApproveMultiNetwork/authkey-tag-advertiseduringup-false-pol-database
|
||||
// - TestAutoApproveMultiNetwork/webauth-user-advertiseduringup-true-pol-file
|
||||
//
|
||||
// Splitting by approver type (tag, user, group) creates 6 CI jobs with 4 tests each:
|
||||
// - TestAutoApproveMultiNetwork/authkey-tag.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/authkey-user.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/authkey-group.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/webauth-tag.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/webauth-user.* (4 tests)
|
||||
// - TestAutoApproveMultiNetwork/webauth-group.* (4 tests)
|
||||
//
|
||||
// This reduces load per CI job (4 tests instead of 12) to avoid infrastructure
|
||||
// flakiness when running many sequential Docker-based integration tests.
|
||||
var testsToSplit = map[string][]string{
|
||||
"TestAutoApproveMultiNetwork": {
|
||||
"authkey-tag",
|
||||
"authkey-user",
|
||||
"authkey-group",
|
||||
"webauth-tag",
|
||||
"webauth-user",
|
||||
"webauth-group",
|
||||
},
|
||||
}
|
||||
|
||||
// expandTests takes a list of test names and expands any that need splitting
|
||||
// into multiple subtest patterns.
|
||||
func expandTests(tests []string) []string {
|
||||
var expanded []string
|
||||
for _, test := range tests {
|
||||
if prefixes, ok := testsToSplit[test]; ok {
|
||||
// This test should be split into multiple jobs.
|
||||
// We append ".*" to each prefix because the CI runner wraps patterns
|
||||
// with ^...$ anchors. Without ".*", a pattern like "authkey$" wouldn't
|
||||
// match "authkey-tag-advertiseduringup-false-pol-database".
|
||||
for _, prefix := range prefixes {
|
||||
expanded = append(expanded, fmt.Sprintf("%s/%s.*", test, prefix))
|
||||
}
|
||||
} else {
|
||||
expanded = append(expanded, test)
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func findTests() []string {
|
||||
rgBin, err := exec.LookPath("rg")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to find rg (ripgrep) binary")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--type", "go",
|
||||
"--regexp", "func (Test.+)\\(.*",
|
||||
"../../integration/",
|
||||
"--replace", "$1",
|
||||
"--sort", "path",
|
||||
"--no-line-number",
|
||||
"--no-filename",
|
||||
"--no-heading",
|
||||
}
|
||||
|
||||
cmd := exec.Command(rgBin, args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to run command: %s", err)
|
||||
}
|
||||
|
||||
tests := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
return tests
|
||||
}
|
||||
|
||||
func updateYAML(tests []string, jobName string, testPath string) {
|
||||
testsForYq := fmt.Sprintf("[%s]", strings.Join(tests, ", "))
|
||||
|
||||
yqCommand := fmt.Sprintf(
|
||||
"yq eval '.jobs.%s.strategy.matrix.test = %s' %s -i",
|
||||
jobName,
|
||||
testsForYq,
|
||||
testPath,
|
||||
)
|
||||
cmd := exec.Command("bash", "-c", yqCommand)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Printf("stdout: %s", stdout.String())
|
||||
log.Printf("stderr: %s", stderr.String())
|
||||
log.Fatalf("failed to run yq command: %s", err)
|
||||
}
|
||||
|
||||
fmt.Printf("YAML file (%s) job %s updated successfully\n", testPath, jobName)
|
||||
}
|
||||
|
||||
func main() {
|
||||
tests := findTests()
|
||||
|
||||
// Expand tests that should be split into multiple jobs
|
||||
expandedTests := expandTests(tests)
|
||||
|
||||
quotedTests := make([]string, len(expandedTests))
|
||||
for i, test := range expandedTests {
|
||||
quotedTests[i] = fmt.Sprintf("\"%s\"", test)
|
||||
}
|
||||
|
||||
// Define selected tests for PostgreSQL
|
||||
postgresTestNames := []string{
|
||||
"TestACLAllowUserDst",
|
||||
"TestPingAllByIP",
|
||||
"TestEphemeral2006DeletedTooQuickly",
|
||||
"TestPingAllByIPManyUpDown",
|
||||
"TestSubnetRouterMultiNetwork",
|
||||
}
|
||||
|
||||
quotedPostgresTests := make([]string, len(postgresTestNames))
|
||||
for i, test := range postgresTestNames {
|
||||
quotedPostgresTests[i] = fmt.Sprintf("\"%s\"", test)
|
||||
}
|
||||
|
||||
// Update both SQLite and PostgreSQL job matrices
|
||||
updateYAML(quotedTests, "sqlite", "./test-integration.yaml")
|
||||
updateYAML(quotedPostgresTests, "postgres", "./test-integration.yaml")
|
||||
}
|
||||
6
.github/workflows/gh-actions-updater.yaml
vendored
6
.github/workflows/gh-actions-updater.yaml
vendored
@@ -1,5 +1,6 @@
|
||||
name: GitHub Actions Version Updater
|
||||
|
||||
# Controls when the action will run.
|
||||
on:
|
||||
schedule:
|
||||
# Automatically run on every Sunday
|
||||
@@ -7,17 +8,16 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
# [Required] Access token with `workflow` scope.
|
||||
token: ${{ secrets.WORKFLOW_SECRET }}
|
||||
|
||||
- name: Run GitHub Actions Version Updater
|
||||
uses: saadmk11/github-actions-version-updater@d8781caf11d11168579c8e5e94f62b068038f442 # v0.9.0
|
||||
uses: saadmk11/github-actions-version-updater@v0.7.1
|
||||
with:
|
||||
# [Required] Access token with `workflow` scope.
|
||||
token: ${{ secrets.WORKFLOW_SECRET }}
|
||||
|
||||
130
.github/workflows/integration-test-template.yml
vendored
130
.github/workflows/integration-test-template.yml
vendored
@@ -1,130 +0,0 @@
|
||||
name: Integration Test Template
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
test:
|
||||
required: true
|
||||
type: string
|
||||
postgres_flag:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
database_name:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
env:
|
||||
# Github does not allow us to access secrets in pull requests,
|
||||
# so this env var is used to check if we have the secret or not.
|
||||
# If we have the secrets, meaning we are running on push in a fork,
|
||||
# there might be secrets available for more debugging.
|
||||
# If TS_OAUTH_CLIENT_ID and TS_OAUTH_SECRET is set, then the job
|
||||
# will join a debug tailscale network, set up SSH and a tmux session.
|
||||
# The SSH will be configured to use the SSH key of the Github user
|
||||
# that triggered the build.
|
||||
HAS_TAILSCALE_SECRET: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Tailscale
|
||||
if: ${{ env.HAS_TAILSCALE_SECRET }}
|
||||
uses: tailscale/github-action@a392da0a182bba0e9613b6243ebd69529b1878aa # v4.1.0
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:gh
|
||||
- name: Setup SSH server for Actor
|
||||
if: ${{ env.HAS_TAILSCALE_SECRET }}
|
||||
uses: alexellis/setup-sshd-actor@master
|
||||
- name: Download headscale image
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: headscale-image
|
||||
path: /tmp/artifacts
|
||||
- name: Download tailscale HEAD image
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: tailscale-head-image
|
||||
path: /tmp/artifacts
|
||||
- name: Download hi binary
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: hi-binary
|
||||
path: /tmp/artifacts
|
||||
- name: Download Go cache
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: go-cache
|
||||
path: /tmp/artifacts
|
||||
- name: Download postgres image
|
||||
if: ${{ inputs.postgres_flag == '--postgres=1' }}
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: postgres-image
|
||||
path: /tmp/artifacts
|
||||
- name: Pin Docker to v28 (avoid v29 breaking changes)
|
||||
run: |
|
||||
# Docker 29 breaks docker build via Go client libraries and
|
||||
# docker load/save with certain tarball formats.
|
||||
# Pin to Docker 28.x until our tooling is updated.
|
||||
# https://github.com/actions/runner-images/issues/13474
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update -qq
|
||||
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
|
||||
sudo apt-get install -y --allow-downgrades \
|
||||
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
|
||||
sudo systemctl restart docker
|
||||
docker version
|
||||
- name: Load Docker images, Go cache, and prepare binary
|
||||
run: |
|
||||
gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load
|
||||
gunzip -c /tmp/artifacts/tailscale-head-image.tar.gz | docker load
|
||||
if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then
|
||||
gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load
|
||||
fi
|
||||
chmod +x /tmp/artifacts/hi
|
||||
docker images
|
||||
# Extract Go cache to host directories for bind mounting
|
||||
mkdir -p /tmp/go-cache
|
||||
tar -xzf /tmp/artifacts/go-cache.tar.gz -C /tmp/go-cache
|
||||
ls -la /tmp/go-cache/ /tmp/go-cache/.cache/
|
||||
- name: Run Integration Test
|
||||
env:
|
||||
HEADSCALE_INTEGRATION_HEADSCALE_IMAGE: headscale:${{ github.sha }}
|
||||
HEADSCALE_INTEGRATION_TAILSCALE_IMAGE: tailscale-head:${{ github.sha }}
|
||||
HEADSCALE_INTEGRATION_POSTGRES_IMAGE: ${{ inputs.postgres_flag == '--postgres=1' && format('postgres:{0}', github.sha) || '' }}
|
||||
HEADSCALE_INTEGRATION_GO_CACHE: /tmp/go-cache/go
|
||||
HEADSCALE_INTEGRATION_GO_BUILD_CACHE: /tmp/go-cache/.cache/go-build
|
||||
run: /tmp/artifacts/hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 "^${{ inputs.test }}$" \
|
||||
--timeout=120m \
|
||||
${{ inputs.postgres_flag }}
|
||||
# Sanitize test name for artifact upload (replace invalid characters: " : < > | * ? \ / with -)
|
||||
- name: Sanitize test name for artifacts
|
||||
if: always()
|
||||
id: sanitize
|
||||
run: echo "name=${TEST_NAME//[\":<>|*?\\\/]/-}" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
TEST_NAME: ${{ inputs.test }}
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ inputs.database_name }}-${{ steps.sanitize.outputs.name }}-logs
|
||||
path: "control_logs/*/*.log"
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ inputs.database_name }}-${{ steps.sanitize.outputs.name }}-artifacts
|
||||
path: control_logs/
|
||||
- name: Setup a blocking tmux session
|
||||
if: ${{ env.HAS_TAILSCALE_SECRET }}
|
||||
uses: alexellis/block-with-tmux-action@master
|
||||
109
.github/workflows/lint.yml
vendored
109
.github/workflows/lint.yml
vendored
@@ -1,93 +1,76 @@
|
||||
---
|
||||
name: Lint
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- name: golangci-lint
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: nix develop --command -- golangci-lint run
|
||||
--new-from-rev=${{github.event.pull_request.base.sha}}
|
||||
--output.text.path=stdout
|
||||
--output.text.print-linter-name
|
||||
--output.text.print-issued-lines
|
||||
--output.text.colors
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.49.0
|
||||
|
||||
# Only block PRs on new problems.
|
||||
# If this is not enabled, we will end up having PRs
|
||||
# blocked because new linters has appared and other
|
||||
# parts of the code is affected.
|
||||
only-new-issues: true
|
||||
|
||||
prettier-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: tj-actions/changed-files@v14.1
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- '**/*.md'
|
||||
- '**/*.yml'
|
||||
- '**/*.yaml'
|
||||
- '**/*.ts'
|
||||
- '**/*.js'
|
||||
- '**/*.sass'
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '**/*.html'
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
files: |
|
||||
*.nix
|
||||
**/*.md
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.ts
|
||||
**/*.js
|
||||
**/*.sass
|
||||
**/*.css
|
||||
**/*.scss
|
||||
**/*.html
|
||||
|
||||
- name: Prettify code
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: nix develop --command -- prettier --no-error-on-unmatched-pattern
|
||||
--ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: creyD/prettier_action@v4.0
|
||||
with:
|
||||
prettier_options: >-
|
||||
--check **/*.{ts,js,md,yaml,yml,sass,css,scss,html}
|
||||
only_changed: false
|
||||
dry: true
|
||||
|
||||
proto-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
- uses: actions/checkout@v2
|
||||
- uses: bufbuild/buf-setup-action@v1.7.0
|
||||
- uses: bufbuild/buf-lint-action@v1
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Buf lint
|
||||
run: nix develop --command -- buf lint proto
|
||||
input: "proto"
|
||||
|
||||
28
.github/workflows/needs-more-info-comment.yml
vendored
28
.github/workflows/needs-more-info-comment.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Needs More Info - Post Comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
post-comment:
|
||||
if: >-
|
||||
github.event.label.name == 'needs-more-info' &&
|
||||
github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
sparse-checkout: .github/label-response/needs-more-info.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Post instruction comment
|
||||
run: gh issue comment "$NUMBER" --body-file .github/label-response/needs-more-info.md
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
98
.github/workflows/needs-more-info-timer.yml
vendored
98
.github/workflows/needs-more-info-timer.yml
vendored
@@ -1,98 +0,0 @@
|
||||
name: Needs More Info - Timer
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # Daily at midnight UTC
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# When a non-bot user comments on a needs-more-info issue, remove the label.
|
||||
remove-label-on-response:
|
||||
if: >-
|
||||
github.repository == 'juanfont/headscale' &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-more-info')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove needs-more-info label
|
||||
run: gh issue edit "$NUMBER" --remove-label needs-more-info
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
# On schedule, close issues that have had no human response for 3 days.
|
||||
close-stale:
|
||||
if: >-
|
||||
github.repository == 'juanfont/headscale' &&
|
||||
github.event_name != 'issue_comment'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: hustcer/setup-nu@920172d92eb04671776f3ba69d605d3b09351c30 # v3.22
|
||||
with:
|
||||
version: "*"
|
||||
|
||||
- name: Close stale needs-more-info issues
|
||||
shell: nu {0}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
let issues = (gh issue list
|
||||
--repo $env.GH_REPO
|
||||
--label "needs-more-info"
|
||||
--state open
|
||||
--json number
|
||||
| from json)
|
||||
|
||||
for issue in $issues {
|
||||
let number = $issue.number
|
||||
print $"Checking issue #($number)"
|
||||
|
||||
# Find when needs-more-info was last added
|
||||
let events = (gh api $"repos/($env.GH_REPO)/issues/($number)/events"
|
||||
--paginate | from json | flatten)
|
||||
let label_event = ($events
|
||||
| where event == "labeled" and label.name == "needs-more-info"
|
||||
| last)
|
||||
let label_added_at = ($label_event.created_at | into datetime)
|
||||
|
||||
# Check for non-bot comments after the label was added
|
||||
let comments = (gh api $"repos/($env.GH_REPO)/issues/($number)/comments"
|
||||
--paginate | from json | flatten)
|
||||
let human_responses = ($comments
|
||||
| where user.type != "Bot"
|
||||
| where { ($in.created_at | into datetime) > $label_added_at })
|
||||
|
||||
if ($human_responses | length) > 0 {
|
||||
print $" Human responded, removing label"
|
||||
gh issue edit $number --repo $env.GH_REPO --remove-label needs-more-info
|
||||
continue
|
||||
}
|
||||
|
||||
# Check if 3 days have passed
|
||||
let elapsed = (date now) - $label_added_at
|
||||
if $elapsed < 3day {
|
||||
print $" Only ($elapsed | format duration day) elapsed, skipping"
|
||||
continue
|
||||
}
|
||||
|
||||
print $" No response for ($elapsed | format duration day), closing"
|
||||
let message = [
|
||||
"This issue has been automatically closed because no additional information was provided within 3 days."
|
||||
""
|
||||
"If you have the requested information, please open a new issue and include the debug information requested above."
|
||||
""
|
||||
"Thank you for your understanding."
|
||||
] | str join "\n"
|
||||
gh issue comment $number --repo $env.GH_REPO --body $message
|
||||
gh issue close $number --repo $env.GH_REPO --reason "not planned"
|
||||
gh issue edit $number --repo $env.GH_REPO --remove-label needs-more-info
|
||||
}
|
||||
55
.github/workflows/nix-module-test.yml
vendored
55
.github/workflows/nix-module-test.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: NixOS Module Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix-module-check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
nix:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
go:
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'cmd/**'
|
||||
- 'hscontrol/**'
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Run NixOS module tests
|
||||
if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true'
|
||||
run: |
|
||||
echo "Running NixOS module integration test..."
|
||||
nix build .#checks.x86_64-linux.headscale -L
|
||||
157
.github/workflows/release.yml
vendored
157
.github/workflows/release.yml
vendored
@@ -9,54 +9,145 @@ on:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
if: github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pin Docker to v28 (avoid v29 breaking changes)
|
||||
run: |
|
||||
# Docker 29 breaks docker build via Go client libraries and
|
||||
# docker load/save with certain tarball formats.
|
||||
# Pin to Docker 28.x until our tooling is updated.
|
||||
# https://github.com/actions/runner-images/issues/13474
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update -qq
|
||||
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
|
||||
sudo apt-get install -y --allow-downgrades \
|
||||
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
|
||||
sudo systemctl restart docker
|
||||
docker version
|
||||
- uses: cachix/install-nix-action@v16
|
||||
|
||||
- name: Run goreleaser
|
||||
run: nix develop --command -- goreleaser release --rm-dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Set up QEMU for multiple platforms
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: arm64,amd64
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/headscale
|
||||
ghcr.io/${{ github.repository_owner }}/headscale
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
type=raw,value=develop
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
push: true
|
||||
context: .
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
- name: Prepare cache for next build
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Run goreleaser
|
||||
run: nix develop --command -- goreleaser release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
docker-debug-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Set up QEMU for multiple platforms
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: arm64,amd64
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache-debug
|
||||
key: ${{ runner.os }}-buildx-debug-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-debug-
|
||||
- name: Docker meta
|
||||
id: meta-debug
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/headscale
|
||||
ghcr.io/${{ github.repository_owner }}/headscale
|
||||
flavor: |
|
||||
suffix=-debug,onlatest=true
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
type=raw,value=develop
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
file: Dockerfile.debug
|
||||
tags: ${{ steps.meta-debug.outputs.tags }}
|
||||
labels: ${{ steps.meta-debug.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache-debug
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-debug-new
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta-debug.outputs.version }}
|
||||
- name: Prepare cache for next build
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache-debug
|
||||
mv /tmp/.buildx-cache-debug-new /tmp/.buildx-cache-debug
|
||||
|
||||
27
.github/workflows/stale.yml
vendored
27
.github/workflows/stale.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Close inactive issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
if: github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 90 days with no
|
||||
activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days
|
||||
since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "no-stale-bot,needs-more-info"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
30
.github/workflows/support-request.yml
vendored
30
.github/workflows/support-request.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Support Request - Close Issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
close-support-request:
|
||||
if: >-
|
||||
github.event.label.name == 'support-request' &&
|
||||
github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
sparse-checkout: .github/label-response/support-request.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Post comment and close issue
|
||||
run: |
|
||||
gh issue comment "$NUMBER" --body-file .github/label-response/support-request.md
|
||||
gh issue close "$NUMBER" --reason "not planned"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
35
.github/workflows/test-integration-cli.yml
vendored
Normal file
35
.github/workflows/test-integration-cli.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Integration Test CLI
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
integration-test-cli:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run CLI integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: nix develop --command -- make test_integration_cli
|
||||
35
.github/workflows/test-integration-derp.yml
vendored
Normal file
35
.github/workflows/test-integration-derp.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Integration Test DERP
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
integration-test-derp:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run Embedded DERP server integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: nix develop --command -- make test_integration_derp
|
||||
47
.github/workflows/test-integration-v2-TestAuthKeyLogoutAndRelogin.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestAuthKeyLogoutAndRelogin.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestAuthKeyLogoutAndRelogin
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestAuthKeyLogoutAndRelogin$"
|
||||
47
.github/workflows/test-integration-v2-TestAuthWebFlowAuthenticationPingAll.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestAuthWebFlowAuthenticationPingAll.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestAuthWebFlowAuthenticationPingAll
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestAuthWebFlowAuthenticationPingAll$"
|
||||
47
.github/workflows/test-integration-v2-TestAuthWebFlowLogoutAndRelogin.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestAuthWebFlowLogoutAndRelogin.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestAuthWebFlowLogoutAndRelogin
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestAuthWebFlowLogoutAndRelogin$"
|
||||
47
.github/workflows/test-integration-v2-TestCreateTailscale.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestCreateTailscale.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestCreateTailscale
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestCreateTailscale$"
|
||||
47
.github/workflows/test-integration-v2-TestEnablingRoutes.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestEnablingRoutes.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestEnablingRoutes
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestEnablingRoutes$"
|
||||
47
.github/workflows/test-integration-v2-TestHeadscale.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestHeadscale.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestHeadscale
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestHeadscale$"
|
||||
47
.github/workflows/test-integration-v2-TestNamespaceCommand.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestNamespaceCommand.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestNamespaceCommand
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestNamespaceCommand$"
|
||||
47
.github/workflows/test-integration-v2-TestOIDCAuthenticationPingAll.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestOIDCAuthenticationPingAll.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestOIDCAuthenticationPingAll
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestOIDCAuthenticationPingAll$"
|
||||
47
.github/workflows/test-integration-v2-TestOIDCExpireNodes.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestOIDCExpireNodes.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestOIDCExpireNodes
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestOIDCExpireNodes$"
|
||||
47
.github/workflows/test-integration-v2-TestPingAllByHostname.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestPingAllByHostname.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestPingAllByHostname
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestPingAllByHostname$"
|
||||
47
.github/workflows/test-integration-v2-TestPingAllByIP.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestPingAllByIP.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestPingAllByIP
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestPingAllByIP$"
|
||||
47
.github/workflows/test-integration-v2-TestPreAuthKeyCommand.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestPreAuthKeyCommand.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestPreAuthKeyCommand
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestPreAuthKeyCommand$"
|
||||
47
.github/workflows/test-integration-v2-TestPreAuthKeyCommandReusableEphemeral.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestPreAuthKeyCommandReusableEphemeral.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestPreAuthKeyCommandReusableEphemeral
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestPreAuthKeyCommandReusableEphemeral$"
|
||||
47
.github/workflows/test-integration-v2-TestPreAuthKeyCommandWithoutExpiry.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestPreAuthKeyCommandWithoutExpiry.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestPreAuthKeyCommandWithoutExpiry
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestPreAuthKeyCommandWithoutExpiry$"
|
||||
47
.github/workflows/test-integration-v2-TestResolveMagicDNS.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestResolveMagicDNS.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestResolveMagicDNS
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestResolveMagicDNS$"
|
||||
47
.github/workflows/test-integration-v2-TestSSHIsBlockedInACL.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestSSHIsBlockedInACL.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestSSHIsBlockedInACL
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestSSHIsBlockedInACL$"
|
||||
47
.github/workflows/test-integration-v2-TestSSHMultipleNamespacesAllToAll.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestSSHMultipleNamespacesAllToAll.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestSSHMultipleNamespacesAllToAll
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestSSHMultipleNamespacesAllToAll$"
|
||||
47
.github/workflows/test-integration-v2-TestSSHNoSSHConfigured.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestSSHNoSSHConfigured.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestSSHNoSSHConfigured
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestSSHNoSSHConfigured$"
|
||||
47
.github/workflows/test-integration-v2-TestSSHOneNamespaceAllToAll.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestSSHOneNamespaceAllToAll.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestSSHOneNamespaceAllToAll
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestSSHOneNamespaceAllToAll$"
|
||||
47
.github/workflows/test-integration-v2-TestSSNamespaceOnlyIsolation.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestSSNamespaceOnlyIsolation.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestSSNamespaceOnlyIsolation
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestSSNamespaceOnlyIsolation$"
|
||||
47
.github/workflows/test-integration-v2-TestTaildrop.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestTaildrop.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestTaildrop
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestTaildrop$"
|
||||
47
.github/workflows/test-integration-v2-TestTailscaleNodesJoiningHeadcale.yaml
vendored
Normal file
47
.github/workflows/test-integration-v2-TestTailscaleNodesJoiningHeadcale.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - TestTailscaleNodesJoiningHeadcale
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^TestTailscaleNodesJoiningHeadcale$"
|
||||
322
.github/workflows/test-integration.yaml
vendored
322
.github/workflows/test-integration.yaml
vendored
@@ -1,322 +0,0 @@
|
||||
name: integration
|
||||
# To debug locally on a branch, and when needing secrets
|
||||
# change this to include `push` so the build is ran on
|
||||
# the main repository.
|
||||
on: [pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
# build: Builds binaries and Docker images once, uploads as artifacts for reuse.
|
||||
# build-postgres: Pulls postgres image separately to avoid Docker Hub rate limits.
|
||||
# sqlite: Runs all integration tests with SQLite backend.
|
||||
# postgres: Runs a subset of tests with PostgreSQL to verify database compatibility.
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
files-changed: ${{ steps.changed-files.outputs.files }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'integration/**'
|
||||
- 'config-example.yaml'
|
||||
- '.github/workflows/test-integration.yaml'
|
||||
- '.github/workflows/integration-test-template.yml'
|
||||
- 'Dockerfile.*'
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
- name: Build binaries and warm Go cache
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
# Build all Go binaries in one nix shell to maximize cache reuse
|
||||
nix develop --command -- bash -c '
|
||||
go build -o hi ./cmd/hi
|
||||
CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale
|
||||
# Build integration test binary to warm the cache with all dependencies
|
||||
go test -c ./integration -o /dev/null 2>/dev/null || true
|
||||
'
|
||||
- name: Upload hi binary
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: hi-binary
|
||||
path: hi
|
||||
retention-days: 10
|
||||
- name: Package Go cache
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
# Package Go module cache and build cache
|
||||
tar -czf go-cache.tar.gz -C ~ go .cache/go-build
|
||||
- name: Upload Go cache
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: go-cache
|
||||
path: go-cache.tar.gz
|
||||
retention-days: 10
|
||||
- name: Pin Docker to v28 (avoid v29 breaking changes)
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
# Docker 29 breaks docker build via Go client libraries and
|
||||
# docker load/save with certain tarball formats.
|
||||
# Pin to Docker 28.x until our tooling is updated.
|
||||
# https://github.com/actions/runner-images/issues/13474
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update -qq
|
||||
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
|
||||
sudo apt-get install -y --allow-downgrades \
|
||||
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
|
||||
sudo systemctl restart docker
|
||||
docker version
|
||||
- name: Build headscale image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
docker build \
|
||||
--file Dockerfile.integration-ci \
|
||||
--tag headscale:${{ github.sha }} \
|
||||
.
|
||||
docker save headscale:${{ github.sha }} | gzip > headscale-image.tar.gz
|
||||
- name: Build tailscale HEAD image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
run: |
|
||||
docker build \
|
||||
--file Dockerfile.tailscale-HEAD \
|
||||
--tag tailscale-head:${{ github.sha }} \
|
||||
.
|
||||
docker save tailscale-head:${{ github.sha }} | gzip > tailscale-head-image.tar.gz
|
||||
- name: Upload headscale image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: headscale-image
|
||||
path: headscale-image.tar.gz
|
||||
retention-days: 10
|
||||
- name: Upload tailscale HEAD image
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: tailscale-head-image
|
||||
path: tailscale-head-image.tar.gz
|
||||
retention-days: 10
|
||||
build-postgres:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs: build
|
||||
if: needs.build.outputs.files-changed == 'true'
|
||||
steps:
|
||||
- name: Pin Docker to v28 (avoid v29 breaking changes)
|
||||
run: |
|
||||
# Docker 29 breaks docker build via Go client libraries and
|
||||
# docker load/save with certain tarball formats.
|
||||
# Pin to Docker 28.x until our tooling is updated.
|
||||
# https://github.com/actions/runner-images/issues/13474
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update -qq
|
||||
VERSION=$(apt-cache madison docker-ce | grep '28\.5' | head -1 | awk '{print $3}')
|
||||
sudo apt-get install -y --allow-downgrades \
|
||||
"docker-ce=${VERSION}" "docker-ce-cli=${VERSION}"
|
||||
sudo systemctl restart docker
|
||||
docker version
|
||||
- name: Pull and save postgres image
|
||||
run: |
|
||||
docker pull postgres:latest
|
||||
docker tag postgres:latest postgres:${{ github.sha }}
|
||||
docker save postgres:${{ github.sha }} | gzip > postgres-image.tar.gz
|
||||
- name: Upload postgres image
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: postgres-image
|
||||
path: postgres-image.tar.gz
|
||||
retention-days: 10
|
||||
sqlite:
|
||||
needs: build
|
||||
if: needs.build.outputs.files-changed == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test:
|
||||
- TestACLHostsInNetMapTable
|
||||
- TestACLAllowUser80Dst
|
||||
- TestACLDenyAllPort80
|
||||
- TestACLAllowUserDst
|
||||
- TestACLAllowStarDst
|
||||
- TestACLNamedHostsCanReachBySubnet
|
||||
- TestACLNamedHostsCanReach
|
||||
- TestACLDevice1CanAccessDevice2
|
||||
- TestPolicyUpdateWhileRunningWithCLIInDatabase
|
||||
- TestACLAutogroupMember
|
||||
- TestACLAutogroupTagged
|
||||
- TestACLAutogroupSelf
|
||||
- TestACLPolicyPropagationOverTime
|
||||
- TestACLTagPropagation
|
||||
- TestACLTagPropagationPortSpecific
|
||||
- TestACLGroupWithUnknownUser
|
||||
- TestACLGroupAfterUserDeletion
|
||||
- TestACLGroupDeletionExactReproduction
|
||||
- TestACLDynamicUnknownUserAddition
|
||||
- TestACLDynamicUnknownUserRemoval
|
||||
- TestAPIAuthenticationBypass
|
||||
- TestAPIAuthenticationBypassCurl
|
||||
- TestGRPCAuthenticationBypass
|
||||
- TestCLIWithConfigAuthenticationBypass
|
||||
- TestAuthKeyLogoutAndReloginSameUser
|
||||
- TestAuthKeyLogoutAndReloginNewUser
|
||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
||||
- TestAuthKeyDeleteKey
|
||||
- TestAuthKeyLogoutAndReloginRoutesPreserved
|
||||
- TestOIDCAuthenticationPingAll
|
||||
- TestOIDCExpireNodesBasedOnTokenExpiry
|
||||
- TestOIDC024UserCreation
|
||||
- TestOIDCAuthenticationWithPKCE
|
||||
- TestOIDCReloginSameNodeNewUser
|
||||
- TestOIDCFollowUpUrl
|
||||
- TestOIDCMultipleOpenedLoginUrls
|
||||
- TestOIDCReloginSameNodeSameUser
|
||||
- TestOIDCExpiryAfterRestart
|
||||
- TestOIDCACLPolicyOnJoin
|
||||
- TestOIDCReloginSameUserRoutesPreserved
|
||||
- TestAuthWebFlowAuthenticationPingAll
|
||||
- TestAuthWebFlowLogoutAndReloginSameUser
|
||||
- TestAuthWebFlowLogoutAndReloginNewUser
|
||||
- TestUserCommand
|
||||
- TestPreAuthKeyCommand
|
||||
- TestPreAuthKeyCommandWithoutExpiry
|
||||
- TestPreAuthKeyCommandReusableEphemeral
|
||||
- TestPreAuthKeyCorrectUserLoggedInCommand
|
||||
- TestTaggedNodesCLIOutput
|
||||
- TestApiKeyCommand
|
||||
- TestNodeCommand
|
||||
- TestNodeExpireCommand
|
||||
- TestNodeRenameCommand
|
||||
- TestPolicyCommand
|
||||
- TestPolicyBrokenConfigCommand
|
||||
- TestDERPVerifyEndpoint
|
||||
- TestResolveMagicDNS
|
||||
- TestResolveMagicDNSExtraRecordsPath
|
||||
- TestDERPServerScenario
|
||||
- TestDERPServerWebsocketScenario
|
||||
- TestPingAllByIP
|
||||
- TestPingAllByIPPublicDERP
|
||||
- TestEphemeral
|
||||
- TestEphemeralInAlternateTimezone
|
||||
- TestEphemeral2006DeletedTooQuickly
|
||||
- TestPingAllByHostname
|
||||
- TestTaildrop
|
||||
- TestUpdateHostnameFromClient
|
||||
- TestExpireNode
|
||||
- TestSetNodeExpiryInFuture
|
||||
- TestDisableNodeExpiry
|
||||
- TestNodeOnlineStatus
|
||||
- TestPingAllByIPManyUpDown
|
||||
- Test2118DeletingOnlineNodePanics
|
||||
- TestGrantCapRelay
|
||||
- TestGrantCapDrive
|
||||
- TestEnablingRoutes
|
||||
- TestHASubnetRouterFailover
|
||||
- TestSubnetRouteACL
|
||||
- TestEnablingExitRoutes
|
||||
- TestSubnetRouterMultiNetwork
|
||||
- TestSubnetRouterMultiNetworkExitNode
|
||||
- TestAutoApproveMultiNetwork/authkey-tag.*
|
||||
- TestAutoApproveMultiNetwork/authkey-user.*
|
||||
- TestAutoApproveMultiNetwork/authkey-group.*
|
||||
- TestAutoApproveMultiNetwork/webauth-tag.*
|
||||
- TestAutoApproveMultiNetwork/webauth-user.*
|
||||
- TestAutoApproveMultiNetwork/webauth-group.*
|
||||
- TestSubnetRouteACLFiltering
|
||||
- TestGrantViaSubnetSteering
|
||||
- TestHeadscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
- TestSSHOneUserToAll
|
||||
- TestSSHMultipleUsersAllToAll
|
||||
- TestSSHNoSSHConfigured
|
||||
- TestSSHIsBlockedInACL
|
||||
- TestSSHUserOnlyIsolation
|
||||
- TestSSHAutogroupSelf
|
||||
- TestSSHOneUserToOneCheckModeCLI
|
||||
- TestSSHOneUserToOneCheckModeOIDC
|
||||
- TestSSHCheckModeUnapprovedTimeout
|
||||
- TestSSHCheckModeCheckPeriodCLI
|
||||
- TestSSHCheckModeAutoApprove
|
||||
- TestSSHCheckModeNegativeCLI
|
||||
- TestSSHLocalpart
|
||||
- TestTagsAuthKeyWithTagRequestDifferentTag
|
||||
- TestTagsAuthKeyWithTagNoAdvertiseFlag
|
||||
- TestTagsAuthKeyWithTagCannotAddViaCLI
|
||||
- TestTagsAuthKeyWithTagCannotChangeViaCLI
|
||||
- TestTagsAuthKeyWithTagAdminOverrideReauthPreserves
|
||||
- TestTagsAuthKeyWithTagCLICannotModifyAdminTags
|
||||
- TestTagsAuthKeyWithoutTagCannotRequestTags
|
||||
- TestTagsAuthKeyWithoutTagRegisterNoTags
|
||||
- TestTagsAuthKeyWithoutTagCannotAddViaCLI
|
||||
- TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset
|
||||
- TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise
|
||||
- TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag
|
||||
- TestTagsUserLoginOwnedTagAtRegistration
|
||||
- TestTagsUserLoginNonExistentTagAtRegistration
|
||||
- TestTagsUserLoginUnownedTagAtRegistration
|
||||
- TestTagsUserLoginAddTagViaCLIReauth
|
||||
- TestTagsUserLoginRemoveTagViaCLIReauth
|
||||
- TestTagsUserLoginCLINoOpAfterAdminAssignment
|
||||
- TestTagsUserLoginCLICannotRemoveAdminTags
|
||||
- TestTagsAuthKeyWithTagRequestNonExistentTag
|
||||
- TestTagsAuthKeyWithTagRequestUnownedTag
|
||||
- TestTagsAuthKeyWithoutTagRequestNonExistentTag
|
||||
- TestTagsAuthKeyWithoutTagRequestUnownedTag
|
||||
- TestTagsAdminAPICannotSetNonExistentTag
|
||||
- TestTagsAdminAPICanSetUnownedTag
|
||||
- TestTagsAdminAPICannotRemoveAllTags
|
||||
- TestTagsIssue2978ReproTagReplacement
|
||||
- TestTagsAdminAPICannotSetInvalidFormat
|
||||
- TestTagsUserLoginReauthWithEmptyTagsRemovesAllTags
|
||||
- TestTagsAuthKeyWithoutUserInheritsTags
|
||||
- TestTagsAuthKeyWithoutUserRejectsAdvertisedTags
|
||||
- TestTagsAuthKeyConvertToUserViaCLIRegister
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
test: ${{ matrix.test }}
|
||||
postgres_flag: "--postgres=0"
|
||||
database_name: "sqlite"
|
||||
postgres:
|
||||
needs: [build, build-postgres]
|
||||
if: needs.build.outputs.files-changed == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test:
|
||||
- TestACLAllowUserDst
|
||||
- TestPingAllByIP
|
||||
- TestEphemeral2006DeletedTooQuickly
|
||||
- TestPingAllByIPManyUpDown
|
||||
- TestSubnetRouterMultiNetwork
|
||||
uses: ./.github/workflows/integration-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
test: ${{ matrix.test }}
|
||||
postgres_flag: "--postgres=1"
|
||||
database_name: "postgres"
|
||||
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -2,46 +2,29 @@ name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
filters: |
|
||||
files:
|
||||
- '*.nix'
|
||||
- 'go.*'
|
||||
- '**/*.go'
|
||||
- 'integration_test/'
|
||||
- 'config-example.yaml'
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
with:
|
||||
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix',
|
||||
'**/flake.lock') }}
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run tests
|
||||
if: steps.changed-files.outputs.files == 'true'
|
||||
env:
|
||||
# As of 2025-01-06, these env vars was not automatically
|
||||
# set anymore which breaks the initdb for postgres on
|
||||
# some of the database migration tests.
|
||||
LC_ALL: "en_US.UTF-8"
|
||||
LC_CTYPE: "en_US.UTF-8"
|
||||
run: nix develop --command -- gotestsum
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: nix develop --check
|
||||
|
||||
19
.github/workflows/update-flake.yml
vendored
19
.github/workflows/update-flake.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: update-flake-lock
|
||||
on:
|
||||
workflow_dispatch: # allows manual triggering
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # runs weekly on Sunday at 00:00
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
if: github.repository == 'juanfont/headscale'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17
|
||||
- name: Update flake.lock
|
||||
uses: DeterminateSystems/update-flake-lock@428c2b58a4b7414dabd372acb6a03dba1084d3ab # v25
|
||||
with:
|
||||
pr-title: "Update flake.lock"
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,11 +1,3 @@
|
||||
ignored/
|
||||
tailscale/
|
||||
.vscode/
|
||||
.claude/
|
||||
logs/
|
||||
|
||||
*.prof
|
||||
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
@@ -20,16 +12,13 @@ logs/
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
vendor/
|
||||
# vendor/
|
||||
|
||||
dist/
|
||||
/headscale
|
||||
config.json
|
||||
config.yaml
|
||||
config*.yaml
|
||||
!config-example.yaml
|
||||
derp.yaml
|
||||
*.hujson
|
||||
!hscontrol/policy/v2/testdata/*/*.hujson
|
||||
*.key
|
||||
/db.sqlite
|
||||
*.sqlite3
|
||||
@@ -37,21 +26,10 @@ derp.yaml
|
||||
# Exclude Jetbrains Editors
|
||||
.idea
|
||||
|
||||
test_output/
|
||||
control_logs/
|
||||
test_output/
|
||||
|
||||
# Nix build output
|
||||
result
|
||||
.direnv/
|
||||
|
||||
integration_test/etc/config.dump.yaml
|
||||
|
||||
# MkDocs
|
||||
.cache
|
||||
/site
|
||||
|
||||
__debug_bin
|
||||
|
||||
node_modules/
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
158
.golangci.yaml
158
.golangci.yaml
@@ -1,108 +1,66 @@
|
||||
---
|
||||
version: "2"
|
||||
run:
|
||||
timeout: 10m
|
||||
build-tags:
|
||||
- ts2019
|
||||
|
||||
issues:
|
||||
skip-dirs:
|
||||
- gen
|
||||
linters:
|
||||
default: all
|
||||
enable-all: true
|
||||
disable:
|
||||
- cyclop
|
||||
- depguard
|
||||
- dupl
|
||||
- exhaustruct
|
||||
- funcorder
|
||||
- funlen
|
||||
- exhaustivestruct
|
||||
- revive
|
||||
- lll
|
||||
- interfacer
|
||||
- scopelint
|
||||
- maligned
|
||||
- golint
|
||||
- gofmt
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- godox
|
||||
- interfacebloat
|
||||
- ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- makezero
|
||||
- mnd
|
||||
- musttag
|
||||
- nestif
|
||||
- nolintlint
|
||||
- paralleltest
|
||||
- revive
|
||||
- funlen
|
||||
- exhaustivestruct
|
||||
- tagliatelle
|
||||
- testpackage
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
- wsl
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
# Forbid time.Sleep everywhere with context-appropriate alternatives
|
||||
- pattern: 'time\.Sleep'
|
||||
msg: >-
|
||||
time.Sleep is forbidden.
|
||||
In tests: use assert.EventuallyWithT for polling/waiting patterns.
|
||||
In production code: use a backoff strategy (e.g., cenkalti/backoff) or proper synchronization primitives.
|
||||
# Forbid inline string literals in zerolog field methods - use zf.* constants
|
||||
- pattern: '\.(Str|Int|Int8|Int16|Int32|Int64|Uint|Uint8|Uint16|Uint32|Uint64|Float32|Float64|Bool|Dur|Time|TimeDiff|Strs|Ints|Uints|Floats|Bools|Any|Interface)\("[^"]+"'
|
||||
msg: >-
|
||||
Use zf.* constants for zerolog field names instead of string literals.
|
||||
Import "github.com/juanfont/headscale/hscontrol/util/zlog/zf" and use
|
||||
constants like zf.NodeID, zf.UserName, etc. Add new constants to
|
||||
hscontrol/util/zlog/zf/fields.go if needed.
|
||||
# Forbid ptr.To - use Go 1.26 new(expr) instead
|
||||
- pattern: 'ptr\.To\('
|
||||
msg: >-
|
||||
ptr.To is forbidden. Use Go 1.26's new(expr) syntax instead.
|
||||
Example: ptr.To(value) → new(value)
|
||||
# Forbid tsaddr.SortPrefixes - use slices.SortFunc with netip.Prefix.Compare
|
||||
- pattern: 'tsaddr\.SortPrefixes'
|
||||
msg: >-
|
||||
tsaddr.SortPrefixes is forbidden. Use Go 1.26's netip.Prefix.Compare instead.
|
||||
Example: slices.SortFunc(prefixes, netip.Prefix.Compare)
|
||||
analyze-types: true
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
- ifElseChain
|
||||
nlreturn:
|
||||
block-size: 4
|
||||
varnamelen:
|
||||
ignore-names:
|
||||
- err
|
||||
- db
|
||||
- id
|
||||
- ip
|
||||
- ok
|
||||
- c
|
||||
- tt
|
||||
- tx
|
||||
- rx
|
||||
- sb
|
||||
- wg
|
||||
- pr
|
||||
- p
|
||||
- p2
|
||||
ignore-type-assert-ok: true
|
||||
ignore-map-index-ok: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- gen
|
||||
- godox
|
||||
- ireturn
|
||||
- execinquery
|
||||
- exhaustruct
|
||||
- nolintlint
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- gen
|
||||
# We should strive to enable these:
|
||||
- wrapcheck
|
||||
- dupl
|
||||
- makezero
|
||||
- maintidx
|
||||
|
||||
# Limits the methods of an interface to 10. We have more in integration tests
|
||||
- interfacebloat
|
||||
|
||||
# We might want to enable this, but it might be a lot of work
|
||||
- cyclop
|
||||
- nestif
|
||||
- wsl # might be incompatible with gofumpt
|
||||
- testpackage
|
||||
- paralleltest
|
||||
|
||||
linters-settings:
|
||||
varnamelen:
|
||||
ignore-type-assert-ok: true
|
||||
ignore-map-index-ok: true
|
||||
ignore-names:
|
||||
- err
|
||||
- db
|
||||
- id
|
||||
- ip
|
||||
- ok
|
||||
- c
|
||||
- tt
|
||||
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
# TODO(kradalby): Remove this
|
||||
- ifElseChain
|
||||
|
||||
200
.goreleaser.yml
200
.goreleaser.yml
@@ -1,160 +1,86 @@
|
||||
---
|
||||
version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy -compat=1.26
|
||||
- go mod vendor
|
||||
- go mod tidy -compat=1.19
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
draft: true
|
||||
header: |
|
||||
## Upgrade
|
||||
|
||||
Please follow the steps outlined in the [upgrade guide](https://headscale.net/stable/setup/upgrade/) to update your existing Headscale installation.
|
||||
|
||||
builds:
|
||||
- id: headscale
|
||||
main: ./cmd/headscale
|
||||
- id: darwin-amd64
|
||||
main: ./cmd/headscale/headscale.go
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
targets:
|
||||
- darwin_amd64
|
||||
- darwin_arm64
|
||||
- freebsd_amd64
|
||||
- linux_amd64
|
||||
- linux_arm64
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
flags:
|
||||
- -mod=readonly
|
||||
ldflags:
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
tags:
|
||||
- ts2019
|
||||
|
||||
- id: darwin-arm64
|
||||
main: ./cmd/headscale/headscale.go
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
flags:
|
||||
- -mod=readonly
|
||||
ldflags:
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
tags:
|
||||
- ts2019
|
||||
|
||||
- id: linux-amd64
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
main: ./cmd/headscale/headscale.go
|
||||
ldflags:
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
tags:
|
||||
- ts2019
|
||||
|
||||
- id: linux-arm64
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- arm64
|
||||
main: ./cmd/headscale/headscale.go
|
||||
ldflags:
|
||||
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
|
||||
tags:
|
||||
- ts2019
|
||||
|
||||
archives:
|
||||
- id: golang-cross
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
|
||||
formats:
|
||||
- binary
|
||||
|
||||
source:
|
||||
enabled: true
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}"
|
||||
format: tar.gz
|
||||
files:
|
||||
- "vendor/"
|
||||
|
||||
nfpms:
|
||||
# Configure nFPM for .deb releases
|
||||
#
|
||||
# See https://goreleaser.com/customization/package/nfpm/
|
||||
#
|
||||
# Useful tools for debugging .debs:
|
||||
# List file contents: dpkg -c dist/headscale...deb
|
||||
# Package metadata: dpkg --info dist/headscale....deb
|
||||
#
|
||||
- ids:
|
||||
- headscale
|
||||
package_name: headscale
|
||||
priority: optional
|
||||
vendor: headscale
|
||||
maintainer: Kristoffer Dalby <kristoffer@dalby.cc>
|
||||
homepage: https://github.com/juanfont/headscale
|
||||
description: |-
|
||||
Open source implementation of the Tailscale control server.
|
||||
Headscale aims to implement a self-hosted, open source alternative to the
|
||||
Tailscale control server. Headscale's goal is to provide self-hosters and
|
||||
hobbyists with an open-source server they can use for their projects and
|
||||
labs. It implements a narrow scope, a single Tailscale network (tailnet),
|
||||
suitable for a personal use, or a small open-source organisation.
|
||||
bindir: /usr/bin
|
||||
section: net
|
||||
formats:
|
||||
- deb
|
||||
contents:
|
||||
- src: ./config-example.yaml
|
||||
dst: /etc/headscale/config.yaml
|
||||
type: config|noreplace
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./packaging/systemd/headscale.service
|
||||
dst: /usr/lib/systemd/system/headscale.service
|
||||
- dst: /var/lib/headscale
|
||||
type: dir
|
||||
- src: ./config-example.yaml
|
||||
dst: /usr/share/doc/headscale/examples/config-example.yaml
|
||||
- src: LICENSE
|
||||
dst: /usr/share/doc/headscale/copyright
|
||||
scripts:
|
||||
postinstall: ./packaging/deb/postinst
|
||||
postremove: ./packaging/deb/postrm
|
||||
preremove: ./packaging/deb/prerm
|
||||
deb:
|
||||
lintian_overrides:
|
||||
- no-changelog # Our CHANGELOG.md uses a different formatting
|
||||
- no-manual-page
|
||||
- statically-linked-binary
|
||||
|
||||
kos:
|
||||
- id: ghcr
|
||||
repositories:
|
||||
- ghcr.io/juanfont/headscale
|
||||
- headscale/headscale
|
||||
|
||||
# bare tells KO to only use the repository
|
||||
# for tagging and naming the container.
|
||||
bare: true
|
||||
base_image: gcr.io/distroless/base-debian13
|
||||
build: headscale
|
||||
main: ./cmd/headscale
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
tags:
|
||||
- "{{ if not .Prerelease }}latest{{ end }}"
|
||||
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}"
|
||||
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}{{ end }}"
|
||||
- "{{ if not .Prerelease }}{{ .Major }}{{ end }}"
|
||||
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}.{{ .Patch }}{{ end }}"
|
||||
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}{{ end }}"
|
||||
- "{{ if not .Prerelease }}v{{ .Major }}{{ end }}"
|
||||
- "{{ if not .Prerelease }}stable{{ else }}unstable{{ end }}"
|
||||
- "{{ .Tag }}"
|
||||
- '{{ trimprefix .Tag "v" }}'
|
||||
- "sha-{{ .ShortCommit }}"
|
||||
creation_time: "{{.CommitTimestamp}}"
|
||||
ko_data_creation_time: "{{.CommitTimestamp}}"
|
||||
|
||||
- id: ghcr-debug
|
||||
repositories:
|
||||
- ghcr.io/juanfont/headscale
|
||||
- headscale/headscale
|
||||
|
||||
bare: true
|
||||
base_image: gcr.io/distroless/base-debian13:debug
|
||||
build: headscale
|
||||
main: ./cmd/headscale
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
tags:
|
||||
- "{{ if not .Prerelease }}latest-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}.{{ .Patch }}-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}{{ .Major }}.{{ .Minor }}-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}{{ .Major }}-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}.{{ .Patch }}-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}v{{ .Major }}.{{ .Minor }}-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}v{{ .Major }}-debug{{ end }}"
|
||||
- "{{ if not .Prerelease }}stable-debug{{ else }}unstable-debug{{ end }}"
|
||||
- "{{ .Tag }}-debug"
|
||||
- '{{ trimprefix .Tag "v" }}-debug'
|
||||
- "sha-{{ .ShortCommit }}-debug"
|
||||
builds:
|
||||
- darwin-amd64
|
||||
- darwin-arm64
|
||||
- linux-amd64
|
||||
- linux-arm64
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format: binary
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
snapshot:
|
||||
version_template: "{{ .Tag }}-next"
|
||||
name_template: "{{ .Tag }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
|
||||
34
.mcp.json
34
.mcp.json
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-code-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@steipete/claude-code-mcp@latest"],
|
||||
"env": {}
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {}
|
||||
},
|
||||
"nixos": {
|
||||
"type": "stdio",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-nixos"],
|
||||
"env": {}
|
||||
},
|
||||
"context7": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {}
|
||||
},
|
||||
"git": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@cyanheads/git-mcp-server"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
[plugin.mkdocs]
|
||||
align_semantic_breaks_in_lists = true
|
||||
@@ -1,62 +0,0 @@
|
||||
# prek/pre-commit configuration for headscale
|
||||
# See: https://prek.j178.dev/quickstart/
|
||||
# See: https://prek.j178.dev/builtin/
|
||||
|
||||
# Global exclusions - ignore generated code
|
||||
exclude: ^gen/
|
||||
|
||||
repos:
|
||||
# Built-in hooks from pre-commit/pre-commit-hooks
|
||||
# prek will use fast-path optimized versions automatically
|
||||
# See: https://prek.j178.dev/builtin/
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
- id: check-merge-conflict
|
||||
- id: check-symlinks
|
||||
- id: check-toml
|
||||
- id: check-xml
|
||||
- id: check-yaml
|
||||
- id: detect-private-key
|
||||
- id: end-of-file-fixer
|
||||
- id: fix-byte-order-marker
|
||||
- id: mixed-line-ending
|
||||
- id: trailing-whitespace
|
||||
|
||||
# Local hooks for project-specific tooling
|
||||
- repo: local
|
||||
hooks:
|
||||
# nixpkgs-fmt for Nix files
|
||||
- id: nixpkgs-fmt
|
||||
name: nixpkgs-fmt
|
||||
entry: nixpkgs-fmt
|
||||
language: system
|
||||
files: \.nix$
|
||||
|
||||
# Prettier for formatting
|
||||
- id: prettier
|
||||
name: prettier
|
||||
entry: prettier --write --list-different
|
||||
language: system
|
||||
exclude: ^docs/
|
||||
types_or: [javascript, jsx, ts, tsx, yaml, json, toml, html, css, scss, sass, markdown]
|
||||
|
||||
# mdformat for docs
|
||||
- id: mdformat
|
||||
name: mdformat
|
||||
entry: mdformat
|
||||
language: system
|
||||
types_or: [markdown]
|
||||
files: ^docs/
|
||||
|
||||
# golangci-lint for Go code quality
|
||||
- id: golangci-lint
|
||||
name: golangci-lint
|
||||
entry: nix develop --command -- golangci-lint run --new-from-rev=HEAD~1 --timeout=5m --fix
|
||||
language: system
|
||||
types: [go]
|
||||
pass_filenames: false
|
||||
@@ -1,2 +1 @@
|
||||
.github/workflows/test-integration-v2*
|
||||
docs/
|
||||
|
||||
291
AGENTS.md
291
AGENTS.md
@@ -1,291 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Behavioural guidance for AI agents working in this repository. Reference
|
||||
material for complex procedures lives next to the code — integration
|
||||
testing is documented in [`cmd/hi/README.md`](cmd/hi/README.md) and
|
||||
[`integration/README.md`](integration/README.md). Read those files
|
||||
before running tests or writing new ones.
|
||||
|
||||
Headscale is an open-source implementation of the Tailscale control server
|
||||
written in Go. It manages node registration, IP allocation, policy
|
||||
enforcement, and DERP routing for self-hosted tailnets.
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
These rules govern how you work in this repo. They are listed first
|
||||
because they shape every other decision.
|
||||
|
||||
### Ask with comprehensive multiple-choice options
|
||||
|
||||
When you need to clarify intent, scope, or approach, use the
|
||||
`AskUserQuestion` tool (or a numbered list fallback) and present the user
|
||||
with a comprehensive set of options. Cover the likely branches explicitly
|
||||
and include an "other — please describe" escape.
|
||||
|
||||
- Bad: _"How should I handle expired nodes?"_
|
||||
- Good: _"How should expired nodes be handled? (a) Remain visible to peers
|
||||
but marked expired (current behaviour); (b) Hidden from peers entirely;
|
||||
(c) Hidden from peers but visible in admin API; (d) Other."_
|
||||
|
||||
This matters more than you think — open-ended questions waste a round
|
||||
trip and often produce a misaligned answer.
|
||||
|
||||
### Read the documented procedure before running complex commands
|
||||
|
||||
Before invoking any `hi` command, integration test, generator, or
|
||||
migration tool, read the referenced README in full —
|
||||
`cmd/hi/README.md` for running tests, `integration/README.md` for
|
||||
writing them. Never guess flags. If the procedure is not documented
|
||||
anywhere, ask the user rather than inventing one.
|
||||
|
||||
### Map once, then act
|
||||
|
||||
Use `Glob` / `Grep` to understand file structure, then execute. Do not
|
||||
re-explore the same area to "double-check" once you have a plan. Do not
|
||||
re-read files you edited in this session — the harness tracks state for
|
||||
you.
|
||||
|
||||
### Fail fast, report up
|
||||
|
||||
If a command fails twice with the same error, stop and report the exact
|
||||
error to the user with context. Do not loop through variants or
|
||||
"try one more thing". A repeated failure means your model of the problem
|
||||
is wrong.
|
||||
|
||||
### Confirm scope for multi-file changes
|
||||
|
||||
Before touching more than three files, show the user which files will
|
||||
change and why. Use plan mode (`ExitPlanMode`) for non-trivial work.
|
||||
|
||||
### Prefer editing existing files
|
||||
|
||||
Do not create new files unless strictly necessary. Do not generate helper
|
||||
abstractions, wrapper utilities, or "just in case" configuration. Three
|
||||
similar lines of code is better than a premature abstraction.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Enter the nix dev shell (Go 1.26.1, buf, golangci-lint, prek)
|
||||
nix develop
|
||||
|
||||
# Full development workflow: fmt + lint + test + build
|
||||
make dev
|
||||
|
||||
# Individual targets
|
||||
make build # build the headscale binary
|
||||
make test # go test ./...
|
||||
make fmt # format Go, docs, proto
|
||||
make lint # lint Go, proto
|
||||
make generate # regenerate protobuf code (after changes to proto/)
|
||||
make clean # remove build artefacts
|
||||
|
||||
# Direct go test invocations
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
|
||||
# Integration tests — read cmd/hi/README.md first
|
||||
go run ./cmd/hi doctor
|
||||
go run ./cmd/hi run "TestName"
|
||||
```
|
||||
|
||||
Go 1.26.1 minimum (per `go.mod:3`). `nix develop` pins the exact toolchain
|
||||
used in CI.
|
||||
|
||||
## Pre-Commit with prek
|
||||
|
||||
`prek` installs git hooks that run the same checks as CI.
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
prek install # one-time setup
|
||||
prek run # run hooks on staged files
|
||||
prek run --all-files # run hooks on the full tree
|
||||
```
|
||||
|
||||
Hooks cover: file hygiene (trailing whitespace, line endings, BOM),
|
||||
syntax validation (JSON/YAML/TOML/XML), merge-conflict markers, private
|
||||
key detection, nixpkgs-fmt, prettier, and `golangci-lint` via
|
||||
`--new-from-rev=HEAD~1` (see `.pre-commit-config.yaml:59`). A manual
|
||||
invocation with an `upstream/main` remote is equivalent:
|
||||
|
||||
```bash
|
||||
golangci-lint run --new-from-rev=upstream/main --timeout=5m --fix
|
||||
```
|
||||
|
||||
`git commit --no-verify` is acceptable only for WIP commits on feature
|
||||
branches — never on `main`.
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
headscale/
|
||||
├── cmd/
|
||||
│ ├── headscale/ # Main headscale server binary
|
||||
│ └── hi/ # Integration test runner (see cmd/hi/README.md)
|
||||
├── hscontrol/ # Core control plane
|
||||
├── integration/ # End-to-end Docker-based tests (see integration/README.md)
|
||||
├── proto/ # Protocol buffer definitions
|
||||
├── gen/ # Generated code (buf output — do not edit)
|
||||
├── docs/ # User and ACL reference documentation
|
||||
└── packaging/ # Distribution packaging
|
||||
```
|
||||
|
||||
### `hscontrol/` packages
|
||||
|
||||
- `app.go`, `handlers.go`, `grpcv1.go`, `noise.go`, `auth.go`, `oidc.go`,
|
||||
`poll.go`, `metrics.go`, `debug.go`, `tailsql.go`, `platform_config.go`
|
||||
— top-level server files
|
||||
- `state/` — central coordinator (`state.go`) and the copy-on-write
|
||||
`NodeStore` (`node_store.go`). All cross-subsystem operations go
|
||||
through `State`.
|
||||
- `db/` — GORM layer, migrations, schema. `node.go`, `users.go`,
|
||||
`api_key.go`, `preauth_keys.go`, `ip.go`, `policy.go`.
|
||||
- `mapper/` — streaming batcher that distributes MapResponses to
|
||||
clients: `batcher.go`, `node_conn.go`, `builder.go`, `mapper.go`.
|
||||
Performance-critical.
|
||||
- `policy/` — `policy/v2/` is **the** policy implementation. The
|
||||
top-level `policy.go` is thin wrappers. There is no v1 directory.
|
||||
- `routes/`, `dns/`, `derp/`, `types/`, `util/`, `templates/`, `capver/`
|
||||
— routing, MagicDNS, relay, core types, helpers, client templates,
|
||||
capability versioning.
|
||||
- `servertest/` — in-memory test harness for server-level tests that
|
||||
don't need Docker. Prefer this over `integration/` when possible.
|
||||
- `assets/` — embedded UI assets.
|
||||
|
||||
### `cmd/hi/` files
|
||||
|
||||
`main.go`, `run.go`, `doctor.go`, `docker.go`, `cleanup.go`, `stats.go`,
|
||||
`README.md`. **Read `cmd/hi/README.md` before running any `hi` command.**
|
||||
|
||||
## Architecture Essentials
|
||||
|
||||
- **`hscontrol/state/state.go`** is the central coordinator. Cross-cutting
|
||||
operations (node updates, policy evaluation, IP allocation) go through
|
||||
the `State` type, not directly to the database.
|
||||
- **`NodeStore`** in `hscontrol/state/node_store.go` is a copy-on-write
|
||||
in-memory cache backed by `atomic.Pointer[Snapshot]`. Every read is a
|
||||
pointer load; writes rebuild a new snapshot and atomically swap. It is
|
||||
the hot path for `MapRequest` processing and peer visibility.
|
||||
- **The map-request sync point** is
|
||||
`State.UpdateNodeFromMapRequest()` in
|
||||
`hscontrol/state/state.go:2351`. This is where Hostinfo changes,
|
||||
endpoint updates, and route advertisements land in the NodeStore.
|
||||
- **Mapper subsystem** streams MapResponses via `batcher.go` and
|
||||
`node_conn.go`. Changes here affect all connected clients.
|
||||
- **Node registration flow**: noise handshake (`noise.go`) → auth
|
||||
(`auth.go`) → state/DB persistence (`state/`, `db/`) → initial map
|
||||
(`mapper/`).
|
||||
|
||||
## Database Migration Rules
|
||||
|
||||
These rules are load-bearing — violating them corrupts production
|
||||
databases. The `migrationsRequiringFKDisabled` map in
|
||||
`hscontrol/db/db.go:962` is frozen as of 2025-07-02 (see the comment at
|
||||
`db.go:989`). All new migrations must:
|
||||
|
||||
1. **Never reorder existing migrations.** Migration order is immutable
|
||||
once committed.
|
||||
2. **Only add new migrations to the end** of the migrations array.
|
||||
3. **Never disable foreign keys.** No new entries in
|
||||
`migrationsRequiringFKDisabled`.
|
||||
4. **Use the migration ID format** `YYYYMMDDHHMM-short-description`
|
||||
(timestamp + descriptive suffix). Example: `202602201200-clear-tagged-node-user-id`.
|
||||
5. **Never rename columns** that later migrations reference. Let
|
||||
`AutoMigrate` create a new column if needed.
|
||||
|
||||
## Tags-as-Identity
|
||||
|
||||
Headscale enforces **tags XOR user ownership**: every node is either
|
||||
tagged (owned by tags) or user-owned (owned by a user namespace), never
|
||||
both. This is a load-bearing architectural invariant.
|
||||
|
||||
- **Use `node.IsTagged()`** (`hscontrol/types/node.go:221`) to determine
|
||||
ownership, not `node.UserID().Valid()`. A tagged node may still have
|
||||
`UserID` set for "created by" tracking — `IsTagged()` is authoritative.
|
||||
- `IsUserOwned()` (`node.go:227`) returns `!IsTagged()`.
|
||||
- Tagged nodes are presented to Tailscale as the special
|
||||
`TaggedDevices` user (`hscontrol/types/users.go`, ID `2147455555`).
|
||||
- `SetTags` validation is enforced by `validateNodeOwnership()` in
|
||||
`hscontrol/state/tags.go`.
|
||||
- Examples and edge cases live in `hscontrol/types/node_tags_test.go`
|
||||
and `hscontrol/grpcv1_test.go` (`TestSetTags_*`).
|
||||
|
||||
**Don't do this**:
|
||||
|
||||
```go
|
||||
if node.UserID().Valid() { /* assume user-owned */ } // WRONG
|
||||
if node.UserID().Valid() && !node.IsTagged() { /* ok */ } // correct
|
||||
```
|
||||
|
||||
## Policy Engine
|
||||
|
||||
`hscontrol/policy/v2/policy.go` is the policy implementation. The
|
||||
top-level `hscontrol/policy/policy.go` contains only wrapper functions
|
||||
around v2. There is no v1 directory.
|
||||
|
||||
Key concepts an agent will encounter:
|
||||
|
||||
- **Autogroups**: `autogroup:self`, `autogroup:member`, `autogroup:internet`
|
||||
- **Tag owners**: IP-based authorization for who can claim a tag
|
||||
- **Route approvals**: auto-approval of subnet routes by policy
|
||||
- **SSH policies**: SSH access control via grants
|
||||
- **HuJSON** parsing for policy files
|
||||
|
||||
For usage examples, read `hscontrol/policy/v2/policy_test.go`. For ACL
|
||||
reference documentation, see `docs/`.
|
||||
|
||||
## Integration Testing
|
||||
|
||||
**Before running any `hi` command, read `cmd/hi/README.md` in full.**
|
||||
Guessing at `hi` flags leads to broken runs and stale containers.
|
||||
|
||||
Test-authoring patterns (`EventuallyWithT`, `IntegrationSkip`, helper
|
||||
variants, scenario setup) are documented in `integration/README.md`.
|
||||
|
||||
Key reminders:
|
||||
|
||||
- Integration test functions **must** start with `IntegrationSkip(t)`.
|
||||
- External calls (`client.Status`, `headscale.ListNodes`, etc.) belong
|
||||
inside `EventuallyWithT`; state-mutating commands (`tailscale set`)
|
||||
must not.
|
||||
- Tests generate ~100 MB of logs per run under `control_logs/{runID}/`.
|
||||
Prune old runs if disk is tight.
|
||||
- Flakes are almost always code, not infrastructure. Read `hs-*.stderr.log`
|
||||
before blaming Docker.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **Commit messages** follow Go-style `package: imperative description`.
|
||||
Recent examples from `git log`:
|
||||
- `db: scope DestroyUser to only delete the target user's pre-auth keys`
|
||||
- `state: fix policy change race in UpdateNodeFromMapRequest`
|
||||
- `integration: fix ACL tests for address-family-specific resolve`
|
||||
|
||||
Not Conventional Commits. No `feat:`/`chore:`/`docs:` prefixes.
|
||||
|
||||
- **Protobuf regeneration**: changes under `proto/` require
|
||||
`make generate` (which runs `buf generate`) and should land in a
|
||||
**separate commit** from the callers that use the regenerated types.
|
||||
- **Formatting** is enforced by `golangci-lint` with `golines` (width 88)
|
||||
and `gofumpt`. Run `make fmt` or rely on the pre-commit hook.
|
||||
- **Logging** uses `zerolog`. Prefer single-line chains
|
||||
(`log.Info().Str(...).Msg(...)`). For 4+ fields or conditional fields,
|
||||
build incrementally and **reassign** the event variable:
|
||||
`e = e.Str("k", v)`. Forgetting to reassign silently drops the field.
|
||||
- **Tests**: prefer `hscontrol/servertest/` for server-level tests that
|
||||
don't need Docker — faster than full integration tests.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Database**: SQLite for local dev, PostgreSQL for integration-heavy
|
||||
tests (`go run ./cmd/hi run "..." --postgres`). Some race conditions
|
||||
only surface on one backend.
|
||||
- **NodeStore writes** rebuild a full snapshot. Measure before changing
|
||||
hot-path code.
|
||||
- **`.claude/agents/` is deprecated.** Do not create new agent files
|
||||
there. Put behavioural guidance in this file and procedural guidance
|
||||
in the nearest README.
|
||||
- **Do not edit `gen/`** — it is regenerated from `proto/` by
|
||||
`make generate`.
|
||||
- **Proto changes + code changes should be two commits**, not one.
|
||||
1127
CHANGELOG.md
1127
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||
may be reported to the community leaders responsible for enforcement
|
||||
on our [Discord server](https://discord.gg/c84AZQhmpx). All complaints
|
||||
at our Discord channel. All complaints
|
||||
will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
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.
|
||||
This model has been chosen to reduce the risk of burnout by limiting the maintenance overhead of reviewing and validating third-party code.
|
||||
|
||||
## Why do we have this model?
|
||||
|
||||
Headscale has a small maintainer team that tries to balance working on the project, fixing bugs and reviewing contributions.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## What do we require?
|
||||
|
||||
A general description is provided here and an explicit list is provided in our pull request template.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Bug fixes
|
||||
|
||||
Headscale is open to code contributions for bug fixes without discussion.
|
||||
|
||||
## Documentation
|
||||
|
||||
If you find mistakes in the documentation, please submit a fix to the documentation.
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Builder image
|
||||
FROM docker.io/golang:1.19-bullseye AS build
|
||||
ARG VERSION=dev
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go install -tags ts2019 -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$VERSION" -a ./cmd/headscale
|
||||
RUN strip /go/bin/headscale
|
||||
RUN test -e /go/bin/headscale
|
||||
|
||||
# Production image
|
||||
FROM gcr.io/distroless/base-debian11
|
||||
|
||||
COPY --from=build /go/bin/headscale /bin/headscale
|
||||
ENV TZ UTC
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
CMD ["headscale"]
|
||||
24
Dockerfile.debug
Normal file
24
Dockerfile.debug
Normal file
@@ -0,0 +1,24 @@
|
||||
# Builder image
|
||||
FROM docker.io/golang:1.19-bullseye AS build
|
||||
ARG VERSION=dev
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go install -tags ts2019 -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$VERSION" -a ./cmd/headscale
|
||||
RUN test -e /go/bin/headscale
|
||||
|
||||
# Debug image
|
||||
FROM docker.io/golang:1.19.0-bullseye
|
||||
|
||||
COPY --from=build /go/bin/headscale /bin/headscale
|
||||
ENV TZ UTC
|
||||
|
||||
# Need to reset the entrypoint or everything will run as a busybox script
|
||||
ENTRYPOINT []
|
||||
EXPOSE 8080/tcp
|
||||
CMD ["headscale"]
|
||||
@@ -1,19 +0,0 @@
|
||||
# For testing purposes only
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
RUN apk add --no-cache git
|
||||
ARG VERSION_BRANCH=main
|
||||
RUN git clone https://github.com/tailscale/tailscale.git --branch=$VERSION_BRANCH --depth=1
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
ARG TARGETARCH
|
||||
RUN GOARCH=$TARGETARCH go install -v ./cmd/derper
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
ENTRYPOINT [ "/usr/local/bin/derper" ]
|
||||
@@ -1,44 +0,0 @@
|
||||
# This Dockerfile and the images produced are for testing headscale,
|
||||
# and are in no way endorsed by Headscale's maintainers as an
|
||||
# official nor supported release or distribution.
|
||||
|
||||
FROM docker.io/golang:1.26.1-trixie AS builder
|
||||
ARG VERSION=dev
|
||||
ENV GOPATH /go
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
# Install delve debugger first - rarely changes, good cache candidate
|
||||
RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
|
||||
# Download dependencies - only invalidated when go.mod/go.sum change
|
||||
COPY go.mod go.sum /go/src/headscale/
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build - invalidated on any source change
|
||||
COPY . .
|
||||
|
||||
# Build debug binary with debug symbols for delve
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -gcflags="all=-N -l" -o /go/bin/headscale ./cmd/headscale
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get --update install --no-install-recommends --yes \
|
||||
bash ca-certificates curl dnsutils findutils iproute2 jq less procps python3 sqlite3 \
|
||||
&& apt-get dist-clean
|
||||
|
||||
RUN mkdir -p /var/run/headscale
|
||||
|
||||
# Copy binaries from builder
|
||||
COPY --from=builder /go/bin/headscale /usr/local/bin/headscale
|
||||
COPY --from=builder /go/bin/dlv /usr/local/bin/dlv
|
||||
|
||||
# Copy source code for delve source-level debugging
|
||||
COPY --from=builder /go/src/headscale /go/src/headscale
|
||||
|
||||
WORKDIR /go/src/headscale
|
||||
|
||||
# Need to reset the entrypoint or everything will run as a busybox script
|
||||
ENTRYPOINT []
|
||||
EXPOSE 8080/tcp 40000/tcp
|
||||
CMD ["dlv", "--listen=0.0.0.0:40000", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/usr/local/bin/headscale", "--"]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Minimal CI image - expects pre-built headscale binary in build context
|
||||
# For local development with delve debugging, use Dockerfile.integration instead
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get --update install --no-install-recommends --yes \
|
||||
bash ca-certificates curl dnsutils findutils iproute2 jq less procps python3 sqlite3 \
|
||||
&& apt-get dist-clean
|
||||
|
||||
RUN mkdir -p /var/run/headscale
|
||||
|
||||
# Copy pre-built headscale binary from build context
|
||||
COPY headscale /usr/local/bin/headscale
|
||||
|
||||
ENTRYPOINT []
|
||||
EXPOSE 8080/tcp
|
||||
CMD ["/usr/local/bin/headscale"]
|
||||
19
Dockerfile.tailscale
Normal file
19
Dockerfile.tailscale
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM ubuntu:latest
|
||||
|
||||
ARG TAILSCALE_VERSION=*
|
||||
ARG TAILSCALE_CHANNEL=stable
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y gnupg curl ssh \
|
||||
&& curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.gpg | apt-key add - \
|
||||
&& curl -fsSL https://pkgs.tailscale.com/${TAILSCALE_CHANNEL}/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN adduser --shell=/bin/bash ssh-it-user
|
||||
|
||||
ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/
|
||||
RUN chmod 644 /usr/local/share/ca-certificates/server.crt
|
||||
|
||||
RUN update-ca-certificates
|
||||
@@ -1,47 +1,24 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
FROM golang:latest
|
||||
|
||||
# This Dockerfile is more or less lifted from tailscale/tailscale
|
||||
# to ensure a similar build process when testing the HEAD of tailscale.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates dnsutils git iptables ssh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM golang:1.26.2-alpine AS build-env
|
||||
RUN useradd --shell=/bin/bash --create-home ssh-it-user
|
||||
|
||||
WORKDIR /go/src
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Replace `RUN git...` with `COPY` and a local checked out version of Tailscale in `./tailscale`
|
||||
# to test specific commits of the Tailscale client. This is useful when trying to find out why
|
||||
# something specific broke between two versions of Tailscale with for example `git bisect`.
|
||||
# COPY ./tailscale .
|
||||
RUN git clone https://github.com/tailscale/tailscale.git
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
WORKDIR /go/tailscale
|
||||
|
||||
RUN git checkout main
|
||||
|
||||
# see build_docker.sh
|
||||
ARG VERSION_LONG=""
|
||||
ENV VERSION_LONG=$VERSION_LONG
|
||||
ARG VERSION_SHORT=""
|
||||
ENV VERSION_SHORT=$VERSION_SHORT
|
||||
ARG VERSION_GIT_HASH=""
|
||||
ENV VERSION_GIT_HASH=$VERSION_GIT_HASH
|
||||
ARG TARGETARCH
|
||||
RUN sh build_dist.sh tailscale.com/cmd/tailscale
|
||||
RUN sh build_dist.sh tailscale.com/cmd/tailscaled
|
||||
|
||||
ARG BUILD_TAGS=""
|
||||
RUN cp tailscale /usr/local/bin/
|
||||
RUN cp tailscaled /usr/local/bin/
|
||||
|
||||
RUN GOARCH=$TARGETARCH go install -tags="${BUILD_TAGS}" -ldflags="\
|
||||
-X tailscale.com/version.longStamp=$VERSION_LONG \
|
||||
-X tailscale.com/version.shortStamp=$VERSION_SHORT \
|
||||
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||
ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/
|
||||
RUN chmod 644 /usr/local/share/ca-certificates/server.crt
|
||||
|
||||
FROM alpine:3.22
|
||||
# Upstream: ca-certificates ip6tables iptables iproute2
|
||||
# Tests: curl python3 (traceroute via BusyBox)
|
||||
RUN apk add --no-cache ca-certificates curl ip6tables iptables iproute2 python3
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
# For compat with the previous run.sh, although ideally you should be
|
||||
# using build_docker.sh which sets an entrypoint for the image.
|
||||
RUN mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh
|
||||
RUN update-ca-certificates
|
||||
|
||||
187
Makefile
187
Makefile
@@ -1,135 +1,90 @@
|
||||
# Headscale Makefile
|
||||
# Modern Makefile following best practices
|
||||
# Calculate version
|
||||
version ?= $(shell git describe --always --tags --dirty)
|
||||
|
||||
# Version calculation
|
||||
VERSION ?= $(shell git describe --always --tags --dirty)
|
||||
rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
|
||||
|
||||
# Build configuration
|
||||
# Determine if OS supports pie
|
||||
GOOS ?= $(shell uname | tr '[:upper:]' '[:lower:]')
|
||||
ifeq ($(filter $(GOOS), openbsd netbsd solaris plan9), )
|
||||
PIE_FLAGS = -buildmode=pie
|
||||
ifeq ($(filter $(GOOS), openbsd netbsd soloaris plan9), )
|
||||
pieflags = -buildmode=pie
|
||||
else
|
||||
endif
|
||||
|
||||
# Tool availability check with nix warning
|
||||
define check_tool
|
||||
@command -v $(1) >/dev/null 2>&1 || { \
|
||||
echo "Warning: $(1) not found. Run 'nix develop' to ensure all dependencies are available."; \
|
||||
exit 1; \
|
||||
}
|
||||
endef
|
||||
TAGS = -tags ts2019
|
||||
|
||||
# Source file collections using shell find for better performance
|
||||
GO_SOURCES := $(shell find . -name '*.go' -not -path './gen/*' -not -path './vendor/*')
|
||||
PROTO_SOURCES := $(shell find . -name '*.proto' -not -path './gen/*' -not -path './vendor/*')
|
||||
PRETTIER_SOURCES := $(shell find . \( -name '*.md' -o -name '*.yaml' -o -name '*.yml' -o -name '*.ts' -o -name '*.js' -o -name '*.html' -o -name '*.css' -o -name '*.scss' -o -name '*.sass' \) -not -path './gen/*' -not -path './vendor/*' -not -path './node_modules/*')
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
all: lint test build
|
||||
|
||||
# Dependency checking
|
||||
.PHONY: check-deps
|
||||
check-deps:
|
||||
$(call check_tool,go)
|
||||
$(call check_tool,golangci-lint)
|
||||
$(call check_tool,gofumpt)
|
||||
$(call check_tool,mdformat)
|
||||
$(call check_tool,prettier)
|
||||
$(call check_tool,clang-format)
|
||||
$(call check_tool,buf)
|
||||
|
||||
# Build targets
|
||||
.PHONY: build
|
||||
build: check-deps $(GO_SOURCES) go.mod go.sum
|
||||
@echo "Building headscale..."
|
||||
go build $(PIE_FLAGS) -ldflags "-X main.version=$(VERSION)" -o headscale ./cmd/headscale
|
||||
|
||||
# Test targets
|
||||
.PHONY: test
|
||||
test: check-deps $(GO_SOURCES) go.mod go.sum
|
||||
@echo "Running Go tests..."
|
||||
go test -race ./...
|
||||
# GO_SOURCES = $(wildcard *.go)
|
||||
# PROTO_SOURCES = $(wildcard **/*.proto)
|
||||
GO_SOURCES = $(call rwildcard,,*.go)
|
||||
PROTO_SOURCES = $(call rwildcard,,*.proto)
|
||||
|
||||
|
||||
# Formatting targets
|
||||
.PHONY: fmt
|
||||
fmt: fmt-go fmt-mdformat fmt-prettier fmt-proto
|
||||
build:
|
||||
nix build
|
||||
|
||||
.PHONY: fmt-go
|
||||
fmt-go: check-deps $(GO_SOURCES)
|
||||
@echo "Formatting Go code..."
|
||||
gofumpt -l -w .
|
||||
golangci-lint run --fix
|
||||
dev: lint test build
|
||||
|
||||
.PHONY: fmt-mdformat
|
||||
fmt-mdformat: check-deps
|
||||
@echo "Formatting documentation..."
|
||||
mdformat docs/
|
||||
test:
|
||||
@go test $(TAGS) -short -coverprofile=coverage.out ./...
|
||||
|
||||
.PHONY: fmt-prettier
|
||||
fmt-prettier: check-deps $(PRETTIER_SOURCES)
|
||||
@echo "Formatting markup and config files..."
|
||||
prettier --write '**/*.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
||||
test_integration: test_integration_cli test_integration_derp test_integration_v2_general
|
||||
|
||||
.PHONY: fmt-proto
|
||||
fmt-proto: check-deps $(PROTO_SOURCES)
|
||||
@echo "Formatting Protocol Buffer files..."
|
||||
clang-format -i $(PROTO_SOURCES)
|
||||
test_integration_cli:
|
||||
docker network rm $$(docker network ls --filter name=headscale --quiet) || true
|
||||
docker network create headscale-test || true
|
||||
docker run -t --rm \
|
||||
--network headscale-test \
|
||||
-v ~/.cache/hs-integration-go:/go \
|
||||
-v $$PWD:$$PWD -w $$PWD \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock golang:1 \
|
||||
go test $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationCLI ./...
|
||||
|
||||
# Linting targets
|
||||
.PHONY: lint
|
||||
lint: lint-go lint-proto
|
||||
test_integration_derp:
|
||||
docker network rm $$(docker network ls --filter name=headscale --quiet) || true
|
||||
docker network create headscale-test || true
|
||||
docker run -t --rm \
|
||||
--network headscale-test \
|
||||
-v ~/.cache/hs-integration-go:/go \
|
||||
-v $$PWD:$$PWD -w $$PWD \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock golang:1 \
|
||||
go test $(TAGS) -failfast -timeout 30m -count=1 -run IntegrationDERP ./...
|
||||
|
||||
.PHONY: lint-go
|
||||
lint-go: check-deps $(GO_SOURCES) go.mod go.sum
|
||||
@echo "Linting Go code..."
|
||||
golangci-lint run --timeout 10m
|
||||
test_integration_v2_general:
|
||||
docker run \
|
||||
-t --rm \
|
||||
-v ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
-v $$PWD:$$PWD -w $$PWD/integration \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test $(TAGS) -failfast ./... -timeout 120m -parallel 8
|
||||
|
||||
.PHONY: lint-proto
|
||||
lint-proto: check-deps $(PROTO_SOURCES)
|
||||
@echo "Linting Protocol Buffer files..."
|
||||
cd proto/ && buf lint
|
||||
coverprofile_func:
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
# Code generation
|
||||
.PHONY: generate
|
||||
generate: check-deps
|
||||
@echo "Generating code..."
|
||||
go generate ./...
|
||||
coverprofile_html:
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# Clean targets
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf headscale gen
|
||||
lint:
|
||||
golangci-lint run --fix --timeout 10m
|
||||
|
||||
# Development workflow
|
||||
.PHONY: dev
|
||||
dev: fmt lint test build
|
||||
fmt:
|
||||
prettier --write '**/**.{ts,js,md,yaml,yml,sass,css,scss,html}'
|
||||
golines --max-len=88 --base-formatter=gofumpt -w $(GO_SOURCES)
|
||||
clang-format -style="{BasedOnStyle: Google, IndentWidth: 4, AlignConsecutiveDeclarations: true, AlignConsecutiveAssignments: true, ColumnLimit: 0}" -i $(PROTO_SOURCES)
|
||||
|
||||
# Help target
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Headscale Development Makefile"
|
||||
@echo ""
|
||||
@echo "Main targets:"
|
||||
@echo " all - Run lint, test, and build (default)"
|
||||
@echo " build - Build headscale binary"
|
||||
@echo " test - Run Go tests"
|
||||
@echo " fmt - Format all code (Go, docs, proto)"
|
||||
@echo " lint - Lint all code (Go, proto)"
|
||||
@echo " generate - Generate code from Protocol Buffers"
|
||||
@echo " dev - Full development workflow (fmt + lint + test + build)"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo ""
|
||||
@echo "Specific targets:"
|
||||
@echo " fmt-go - Format Go code only"
|
||||
@echo " fmt-mdformat - Format documentation only"
|
||||
@echo " fmt-prettier - Format markup and config files only"
|
||||
@echo " fmt-proto - Format Protocol Buffer files only"
|
||||
@echo " lint-go - Lint Go code only"
|
||||
@echo " lint-proto - Lint Protocol Buffer files only"
|
||||
@echo ""
|
||||
@echo "Dependencies:"
|
||||
@echo " check-deps - Verify required tools are available"
|
||||
@echo ""
|
||||
@echo "Note: If not running in a nix shell, ensure dependencies are available:"
|
||||
@echo " nix develop"
|
||||
proto-lint:
|
||||
cd proto/ && go run github.com/bufbuild/buf/cmd/buf lint
|
||||
|
||||
compress: build
|
||||
upx --brute headscale
|
||||
|
||||
generate:
|
||||
rm -rf gen
|
||||
go run github.com/bufbuild/buf/cmd/buf generate proto
|
||||
|
||||
install-protobuf-plugins:
|
||||
go install \
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \
|
||||
google.golang.org/protobuf/cmd/protoc-gen-go \
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc
|
||||
|
||||
780
README.md
780
README.md
@@ -1,18 +1,14 @@
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
An open source, self-hosted implementation of the Tailscale control server.
|
||||
|
||||
Join our [Discord server](https://discord.gg/c84AZQhmpx) for a chat.
|
||||
Join our [Discord](https://discord.gg/c84AZQhmpx) server for a chat.
|
||||
|
||||
**Note:** Always select the same GitHub tag as the released version you use
|
||||
to ensure you have the correct example configuration. The `main` branch might
|
||||
contain unreleased changes. The documentation is available for stable and
|
||||
development versions:
|
||||
|
||||
- [Documentation for the stable version](https://headscale.net/stable/)
|
||||
- [Documentation for the development version](https://headscale.net/development/)
|
||||
to ensure you have the correct example configuration and documentation.
|
||||
The `main` branch might contain unreleased changes.
|
||||
|
||||
## What is Tailscale
|
||||
|
||||
@@ -36,69 +32,71 @@ organisation.
|
||||
|
||||
## Design goal
|
||||
|
||||
Headscale aims to implement a self-hosted, open source alternative to the
|
||||
[Tailscale](https://tailscale.com/) control server. Headscale's goal is to
|
||||
provide self-hosters and hobbyists with an open-source server they can use for
|
||||
their projects and labs. It implements a narrow scope, a _single_ Tailscale
|
||||
network (tailnet), suitable for a personal use, or a small open-source
|
||||
organisation.
|
||||
`headscale` aims to implement a self-hosted, open source alternative to the Tailscale
|
||||
control server. `headscale` has a narrower scope and an instance of `headscale`
|
||||
implements a _single_ Tailnet, which is typically what a single organisation, or
|
||||
home/personal setup would use.
|
||||
|
||||
## Supporting Headscale
|
||||
`headscale` uses terms that maps to Tailscale's control server, consult the
|
||||
[glossary](./docs/glossary.md) for explainations.
|
||||
|
||||
## Support
|
||||
|
||||
If you like `headscale` and find it useful, there is a sponsorship and donation
|
||||
buttons available in the repo.
|
||||
|
||||
If you would like to sponsor features, bugs or prioritisation, reach out to
|
||||
one of the maintainers.
|
||||
|
||||
## Features
|
||||
|
||||
Please see ["Features" in the documentation](https://headscale.net/stable/about/features/).
|
||||
- Full "base" support of Tailscale's features
|
||||
- Configurable DNS
|
||||
- [Split DNS](https://tailscale.com/kb/1054/dns/#using-dns-settings-in-the-admin-console)
|
||||
- Node registration
|
||||
- Single-Sign-On (via Open ID Connect)
|
||||
- Pre authenticated key
|
||||
- Taildrop (File Sharing)
|
||||
- [Access control lists](https://tailscale.com/kb/1018/acls/)
|
||||
- [MagicDNS](https://tailscale.com/kb/1081/magicdns)
|
||||
- Support for multiple IP ranges in the tailnet
|
||||
- Dual stack (IPv4 and IPv6)
|
||||
- Routing advertising (including exit nodes)
|
||||
- Ephemeral nodes
|
||||
- Embedded [DERP server](https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp)
|
||||
|
||||
## Client OS support
|
||||
|
||||
Please see ["Client and operating system support" in the documentation](https://headscale.net/stable/about/clients/).
|
||||
| OS | Supports headscale |
|
||||
| ------- | --------------------------------------------------------- |
|
||||
| Linux | Yes |
|
||||
| OpenBSD | Yes |
|
||||
| FreeBSD | Yes |
|
||||
| macOS | Yes (see `/apple` on your headscale for more information) |
|
||||
| Windows | Yes [docs](./docs/windows-client.md) |
|
||||
| Android | Yes [docs](./docs/android-client.md) |
|
||||
| iOS | Not yet |
|
||||
|
||||
## Running headscale
|
||||
|
||||
**Please note that we do not support nor encourage the use of reverse proxies
|
||||
and container to run Headscale.**
|
||||
|
||||
Please have a look at the [`documentation`](https://headscale.net/stable/).
|
||||
|
||||
For NixOS users, a module is available in [`nix/`](./nix/).
|
||||
|
||||
## Builds from `main`
|
||||
|
||||
Development builds from the `main` branch are available as container images and
|
||||
binaries. See the [development builds](https://headscale.net/stable/setup/install/main/)
|
||||
documentation for details.
|
||||
|
||||
## Talks
|
||||
|
||||
- Fosdem 2026 (video): [Headscale & Tailscale: The complementary open source clone](https://fosdem.org/2026/schedule/event/KYQ3LL-headscale-the-complementary-open-source-clone/)
|
||||
- presented by Kristoffer Dalby
|
||||
- Fosdem 2023 (video): [Headscale: How we are using integration testing to reimplement Tailscale](https://fosdem.org/2023/schedule/event/goheadscale/)
|
||||
- presented by Juan Font Alonso and Kristoffer Dalby
|
||||
Please have a look at the documentation under [`docs/`](docs/).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is not associated with Tailscale Inc.
|
||||
|
||||
However, one of the active maintainers for Headscale [is employed by Tailscale](https://tailscale.com/blog/opensource) and he is allowed to spend work hours contributing to the project. Contributions from this maintainer are reviewed by other maintainers.
|
||||
|
||||
The maintainers work together on setting the direction for the project. The underlying principle is to serve the community of self-hosters, enthusiasts and hobbyists - while having a sustainable project.
|
||||
1. We have nothing to do with Tailscale, or Tailscale Inc.
|
||||
2. The purpose of Headscale is maintaining a working, self-hosted Tailscale control panel.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
|
||||
|
||||
### Requirements
|
||||
|
||||
To contribute to headscale you would need the latest version of [Go](https://golang.org)
|
||||
and [Buf](https://buf.build) (Protobuf generator).
|
||||
To contribute to headscale you would need the lastest version of [Go](https://golang.org)
|
||||
and [Buf](https://buf.build)(Protobuf generator).
|
||||
|
||||
We recommend using [Nix](https://nixos.org/) to setup a development environment. This can
|
||||
be done with `nix develop`, which will install the tools and give you a shell.
|
||||
This guarantees that you will have the same dev env as `headscale` maintainers.
|
||||
|
||||
PRs and suggestions are welcome.
|
||||
|
||||
### Code style
|
||||
|
||||
To ensure we have some consistency with a growing number of contributions,
|
||||
@@ -113,8 +111,6 @@ run `make lint` and `make fmt` before committing any code.
|
||||
The **Proto** code is linted with [`buf`](https://docs.buf.build/lint/overview) and
|
||||
formatted with [`clang-format`](https://clang.llvm.org/docs/ClangFormat.html).
|
||||
|
||||
The **docs** are formatted with [`mdformat`](https://mdformat.readthedocs.io).
|
||||
|
||||
The **rest** (Markdown, YAML, etc) is formatted with [`prettier`](https://prettier.io).
|
||||
|
||||
Check out the `.golangci.yaml` and `Makefile` to see the specific configuration.
|
||||
@@ -151,34 +147,676 @@ make test
|
||||
To build the program:
|
||||
|
||||
```shell
|
||||
make build
|
||||
nix build
|
||||
```
|
||||
|
||||
### Development workflow
|
||||
|
||||
We recommend using Nix for dependency management to ensure you have all required tools. If you prefer to manage dependencies yourself, you can use Make directly:
|
||||
|
||||
**With Nix (recommended):**
|
||||
or
|
||||
|
||||
```shell
|
||||
nix develop
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
**With your own dependencies:**
|
||||
|
||||
```shell
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
The Makefile will warn you if any required tools are missing and suggest running `nix develop`. Run `make help` to see all available targets.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/juanfont/headscale/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=juanfont/headscale" />
|
||||
</a>
|
||||
|
||||
Made with [contrib.rocks](https://contrib.rocks).
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/kradalby>
|
||||
<img src=https://avatars.githubusercontent.com/u/98431?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kristoffer Dalby/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Kristoffer Dalby</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/juanfont>
|
||||
<img src=https://avatars.githubusercontent.com/u/181059?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Juan Font/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Juan Font</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/restanrm>
|
||||
<img src=https://avatars.githubusercontent.com/u/4344371?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Adrien Raffin-Caboisse/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Adrien Raffin-Caboisse</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/cure>
|
||||
<img src=https://avatars.githubusercontent.com/u/149135?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Ward Vandewege/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Ward Vandewege</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/huskyii>
|
||||
<img src=https://avatars.githubusercontent.com/u/5499746?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jiang Zhu/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Jiang Zhu</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/tsujamin>
|
||||
<img src=https://avatars.githubusercontent.com/u/2435619?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Benjamin Roberts/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Benjamin Roberts</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/reynico>
|
||||
<img src=https://avatars.githubusercontent.com/u/715768?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Nico/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Nico</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/e-zk>
|
||||
<img src=https://avatars.githubusercontent.com/u/58356365?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=e-zk/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>e-zk</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/arch4ngel>
|
||||
<img src=https://avatars.githubusercontent.com/u/11574161?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Justin Angel/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Justin Angel</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ItalyPaleAle>
|
||||
<img src=https://avatars.githubusercontent.com/u/43508?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Alessandro (Ale) Segala/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Alessandro (Ale) Segala</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/unreality>
|
||||
<img src=https://avatars.githubusercontent.com/u/352522?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=unreality/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>unreality</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ohdearaugustin>
|
||||
<img src=https://avatars.githubusercontent.com/u/14001491?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ohdearaugustin/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>ohdearaugustin</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/mpldr>
|
||||
<img src=https://avatars.githubusercontent.com/u/33086936?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Moritz Poldrack/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Moritz Poldrack</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/GrigoriyMikhalkin>
|
||||
<img src=https://avatars.githubusercontent.com/u/3637857?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=GrigoriyMikhalkin/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>GrigoriyMikhalkin</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/mike-lloyd03>
|
||||
<img src=https://avatars.githubusercontent.com/u/49411532?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Mike Lloyd/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Mike Lloyd</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/iSchluff>
|
||||
<img src=https://avatars.githubusercontent.com/u/1429641?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Anton Schubert/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Anton Schubert</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Niek>
|
||||
<img src=https://avatars.githubusercontent.com/u/213140?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Niek van der Maas/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Niek van der Maas</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/negbie>
|
||||
<img src=https://avatars.githubusercontent.com/u/20154956?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Eugen Biegler/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Eugen Biegler</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/617a7a>
|
||||
<img src=https://avatars.githubusercontent.com/u/67651251?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Azz/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Azz</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/evenh>
|
||||
<img src=https://avatars.githubusercontent.com/u/2701536?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Even Holthe/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Even Holthe</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/qbit>
|
||||
<img src=https://avatars.githubusercontent.com/u/68368?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Aaron Bieber/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Aaron Bieber</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/kazauwa>
|
||||
<img src=https://avatars.githubusercontent.com/u/12330159?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Igor Perepilitsyn/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Igor Perepilitsyn</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Aluxima>
|
||||
<img src=https://avatars.githubusercontent.com/u/16262531?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Laurent Marchaud/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Laurent Marchaud</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/fdelucchijr>
|
||||
<img src=https://avatars.githubusercontent.com/u/69133647?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Fernando De Lucchi/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Fernando De Lucchi</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/OrvilleQ>
|
||||
<img src=https://avatars.githubusercontent.com/u/21377465?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Orville Q. Song/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Orville Q. Song</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/hdhoang>
|
||||
<img src=https://avatars.githubusercontent.com/u/12537?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=hdhoang/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>hdhoang</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/bravechamp>
|
||||
<img src=https://avatars.githubusercontent.com/u/48980452?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=bravechamp/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>bravechamp</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/deonthomasgy>
|
||||
<img src=https://avatars.githubusercontent.com/u/150036?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Deon Thomas/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Deon Thomas</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/madjam002>
|
||||
<img src=https://avatars.githubusercontent.com/u/679137?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jamie Greeff/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Jamie Greeff</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ChibangLW>
|
||||
<img src=https://avatars.githubusercontent.com/u/22293464?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ChibangLW/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>ChibangLW</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/mevansam>
|
||||
<img src=https://avatars.githubusercontent.com/u/403630?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Mevan Samaratunga/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Mevan Samaratunga</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/dragetd>
|
||||
<img src=https://avatars.githubusercontent.com/u/3639577?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Michael G./>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Michael G.</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ptman>
|
||||
<img src=https://avatars.githubusercontent.com/u/24669?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Paul Tötterman/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Paul Tötterman</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/samson4649>
|
||||
<img src=https://avatars.githubusercontent.com/u/12725953?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Samuel Lock/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Samuel Lock</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/majst01>
|
||||
<img src=https://avatars.githubusercontent.com/u/410110?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Stefan Majer/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Stefan Majer</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/kevin1sMe>
|
||||
<img src=https://avatars.githubusercontent.com/u/6886076?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=kevinlin/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>kevinlin</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/artemklevtsov>
|
||||
<img src=https://avatars.githubusercontent.com/u/603798?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Artem Klevtsov/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Artem Klevtsov</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/cmars>
|
||||
<img src=https://avatars.githubusercontent.com/u/23741?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Casey Marshall/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Casey Marshall</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/CNLHC>
|
||||
<img src=https://avatars.githubusercontent.com/u/21005146?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=LiuHanCheng/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>LiuHanCheng</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/pvinis>
|
||||
<img src=https://avatars.githubusercontent.com/u/100233?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Pavlos Vinieratos/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Pavlos Vinieratos</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/SilverBut>
|
||||
<img src=https://avatars.githubusercontent.com/u/6560655?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Silver Bullet/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Silver Bullet</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/snh>
|
||||
<img src=https://avatars.githubusercontent.com/u/2051768?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Steven Honson/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Steven Honson</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ratsclub>
|
||||
<img src=https://avatars.githubusercontent.com/u/25647735?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Victor Freire/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Victor Freire</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/lachy2849>
|
||||
<img src=https://avatars.githubusercontent.com/u/98844035?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=lachy2849/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>lachy2849</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/t56k>
|
||||
<img src=https://avatars.githubusercontent.com/u/12165422?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=thomas/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>thomas</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/aberoham>
|
||||
<img src=https://avatars.githubusercontent.com/u/586805?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Abraham Ingersoll/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Abraham Ingersoll</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/puzpuzpuz>
|
||||
<img src=https://avatars.githubusercontent.com/u/37772591?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Andrei Pechkurov/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Andrei Pechkurov</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/apognu>
|
||||
<img src=https://avatars.githubusercontent.com/u/3017182?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Antoine POPINEAU/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Antoine POPINEAU</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/aofei>
|
||||
<img src=https://avatars.githubusercontent.com/u/5037285?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Aofei Sheng/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Aofei Sheng</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/arnarg>
|
||||
<img src=https://avatars.githubusercontent.com/u/1291396?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Arnar/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Arnar</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/awoimbee>
|
||||
<img src=https://avatars.githubusercontent.com/u/22431493?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Arthur Woimbée/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Arthur Woimbée</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/stensonb>
|
||||
<img src=https://avatars.githubusercontent.com/u/933389?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Bryan Stenson/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Bryan Stenson</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/yangchuansheng>
|
||||
<img src=https://avatars.githubusercontent.com/u/15308462?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt= Carson Yang/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b> Carson Yang</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/kundel>
|
||||
<img src=https://avatars.githubusercontent.com/u/10158899?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=kundel/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>kundel</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/fkr>
|
||||
<img src=https://avatars.githubusercontent.com/u/51063?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Felix Kronlage-Dammers/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Felix Kronlage-Dammers</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/felixonmars>
|
||||
<img src=https://avatars.githubusercontent.com/u/1006477?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Felix Yan/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Felix Yan</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/JJGadgets>
|
||||
<img src=https://avatars.githubusercontent.com/u/5709019?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=JJGadgets/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>JJGadgets</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/jimt>
|
||||
<img src=https://avatars.githubusercontent.com/u/180326?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jim Tittsler/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Jim Tittsler</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ShadowJonathan>
|
||||
<img src=https://avatars.githubusercontent.com/u/22740616?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jonathan de Jong/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Jonathan de Jong</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/piec>
|
||||
<img src=https://avatars.githubusercontent.com/u/781471?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Pierre Carru/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Pierre Carru</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Donran>
|
||||
<img src=https://avatars.githubusercontent.com/u/4838348?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Pontus N/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Pontus N</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/nnsee>
|
||||
<img src=https://avatars.githubusercontent.com/u/36747857?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Rasmus Moorats/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Rasmus Moorats</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/rcursaru>
|
||||
<img src=https://avatars.githubusercontent.com/u/16259641?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=rcursaru/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>rcursaru</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/renovate-bot>
|
||||
<img src=https://avatars.githubusercontent.com/u/25180681?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Mend Renovate/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Mend Renovate</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ryanfowler>
|
||||
<img src=https://avatars.githubusercontent.com/u/2668821?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Ryan Fowler/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Ryan Fowler</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/shaananc>
|
||||
<img src=https://avatars.githubusercontent.com/u/2287839?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Shaanan Cohney/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Shaanan Cohney</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/stefanvanburen>
|
||||
<img src=https://avatars.githubusercontent.com/u/622527?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Stefan VanBuren/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Stefan VanBuren</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/sophware>
|
||||
<img src=https://avatars.githubusercontent.com/u/41669?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=sophware/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>sophware</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/m-tanner-dev0>
|
||||
<img src=https://avatars.githubusercontent.com/u/97977342?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tanner/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Tanner</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Teteros>
|
||||
<img src=https://avatars.githubusercontent.com/u/5067989?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Teteros/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Teteros</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/gitter-badger>
|
||||
<img src=https://avatars.githubusercontent.com/u/8518239?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=The Gitter Badger/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>The Gitter Badger</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/tianon>
|
||||
<img src=https://avatars.githubusercontent.com/u/161631?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tianon Gravi/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Tianon Gravi</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/thetillhoff>
|
||||
<img src=https://avatars.githubusercontent.com/u/25052289?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Till Hoffmann/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Till Hoffmann</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/woudsma>
|
||||
<img src=https://avatars.githubusercontent.com/u/6162978?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tjerk Woudsma/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Tjerk Woudsma</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/y0ngb1n>
|
||||
<img src=https://avatars.githubusercontent.com/u/25719408?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Yang Bin/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Yang Bin</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/gozssky>
|
||||
<img src=https://avatars.githubusercontent.com/u/17199941?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Yujie Xia/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Yujie Xia</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/newellz2>
|
||||
<img src=https://avatars.githubusercontent.com/u/52436542?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Zachary N./>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Zachary N.</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/zekker6>
|
||||
<img src=https://avatars.githubusercontent.com/u/1367798?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Zakhar Bessarab/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Zakhar Bessarab</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/zhzy0077>
|
||||
<img src=https://avatars.githubusercontent.com/u/8717471?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Zhiyuan Zheng/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Zhiyuan Zheng</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Bpazy>
|
||||
<img src=https://avatars.githubusercontent.com/u/9838749?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Ziyuan Han/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Ziyuan Han</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/derelm>
|
||||
<img src=https://avatars.githubusercontent.com/u/465155?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=derelm/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>derelm</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/nning>
|
||||
<img src=https://avatars.githubusercontent.com/u/557430?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=henning mueller/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>henning mueller</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/ignoramous>
|
||||
<img src=https://avatars.githubusercontent.com/u/852289?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ignoramous/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>ignoramous</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/magichuihui>
|
||||
<img src=https://avatars.githubusercontent.com/u/10866198?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=suhelen/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>suhelen</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/lion24>
|
||||
<img src=https://avatars.githubusercontent.com/u/1382102?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=sharkonet/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>sharkonet</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/manju-rn>
|
||||
<img src=https://avatars.githubusercontent.com/u/26291847?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=manju-rn/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>manju-rn</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/pernila>
|
||||
<img src=https://avatars.githubusercontent.com/u/12460060?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=pernila/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>pernila</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/phpmalik>
|
||||
<img src=https://avatars.githubusercontent.com/u/26834645?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=phpmalik/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>phpmalik</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/Wakeful-Cloud>
|
||||
<img src=https://avatars.githubusercontent.com/u/38930607?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Wakeful-Cloud/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>Wakeful-Cloud</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
|
||||
<a href=https://github.com/xpzouying>
|
||||
<img src=https://avatars.githubusercontent.com/u/3946563?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=zy/>
|
||||
<br />
|
||||
<sub style="font-size:14px"><b>zy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
699
acls.go
Normal file
699
acls.go
Normal file
@@ -0,0 +1,699 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tailscale/hujson"
|
||||
"gopkg.in/yaml.v3"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const (
|
||||
errEmptyPolicy = Error("empty policy")
|
||||
errInvalidAction = Error("invalid action")
|
||||
errInvalidGroup = Error("invalid group")
|
||||
errInvalidTag = Error("invalid tag")
|
||||
errInvalidPortFormat = Error("invalid port format")
|
||||
errWildcardIsNeeded = Error("wildcard as port is required for the protocol")
|
||||
)
|
||||
|
||||
const (
|
||||
Base8 = 8
|
||||
Base10 = 10
|
||||
BitSize16 = 16
|
||||
BitSize32 = 32
|
||||
BitSize64 = 64
|
||||
portRangeBegin = 0
|
||||
portRangeEnd = 65535
|
||||
expectedTokenItems = 2
|
||||
)
|
||||
|
||||
// For some reason golang.org/x/net/internal/iana is an internal package.
|
||||
const (
|
||||
protocolICMP = 1 // Internet Control Message
|
||||
protocolIGMP = 2 // Internet Group Management
|
||||
protocolIPv4 = 4 // IPv4 encapsulation
|
||||
protocolTCP = 6 // Transmission Control
|
||||
protocolEGP = 8 // Exterior Gateway Protocol
|
||||
protocolIGP = 9 // any private interior gateway (used by Cisco for their IGRP)
|
||||
protocolUDP = 17 // User Datagram
|
||||
protocolGRE = 47 // Generic Routing Encapsulation
|
||||
protocolESP = 50 // Encap Security Payload
|
||||
protocolAH = 51 // Authentication Header
|
||||
protocolIPv6ICMP = 58 // ICMP for IPv6
|
||||
protocolSCTP = 132 // Stream Control Transmission Protocol
|
||||
ProtocolFC = 133 // Fibre Channel
|
||||
)
|
||||
|
||||
var featureEnableSSH = envknob.RegisterBool("HEADSCALE_EXPERIMENTAL_FEATURE_SSH")
|
||||
|
||||
// LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules.
|
||||
func (h *Headscale) LoadACLPolicy(path string) error {
|
||||
log.Debug().
|
||||
Str("func", "LoadACLPolicy").
|
||||
Str("path", path).
|
||||
Msg("Loading ACL policy from path")
|
||||
|
||||
policyFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer policyFile.Close()
|
||||
|
||||
var policy ACLPolicy
|
||||
policyBytes, err := io.ReadAll(policyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch filepath.Ext(path) {
|
||||
case ".yml", ".yaml":
|
||||
log.Debug().
|
||||
Str("path", path).
|
||||
Bytes("file", policyBytes).
|
||||
Msg("Loading ACLs from YAML")
|
||||
|
||||
err := yaml.Unmarshal(policyBytes, &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
Interface("policy", policy).
|
||||
Msg("Loaded policy from YAML")
|
||||
|
||||
default:
|
||||
ast, err := hujson.Parse(policyBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ast.Standardize()
|
||||
policyBytes = ast.Pack()
|
||||
err = json.Unmarshal(policyBytes, &policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if policy.IsZero() {
|
||||
return errEmptyPolicy
|
||||
}
|
||||
|
||||
h.aclPolicy = &policy
|
||||
|
||||
return h.UpdateACLRules()
|
||||
}
|
||||
|
||||
func (h *Headscale) UpdateACLRules() error {
|
||||
machines, err := h.ListMachines()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.aclPolicy == nil {
|
||||
return errEmptyPolicy
|
||||
}
|
||||
|
||||
rules, err := generateACLRules(machines, *h.aclPolicy, h.cfg.OIDC.StripEmaildomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
|
||||
h.aclRules = rules
|
||||
|
||||
if featureEnableSSH() {
|
||||
sshRules, err := h.generateSSHRules()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace().Interface("SSH", sshRules).Msg("SSH rules generated")
|
||||
if h.sshPolicy == nil {
|
||||
h.sshPolicy = &tailcfg.SSHPolicy{}
|
||||
}
|
||||
h.sshPolicy.Rules = sshRules
|
||||
} else if h.aclPolicy != nil && len(h.aclPolicy.SSHs) > 0 {
|
||||
log.Info().Msg("SSH ACLs has been defined, but HEADSCALE_EXPERIMENTAL_FEATURE_SSH is not enabled, this is a unstable feature, check docs before activating")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateACLRules(machines []Machine, aclPolicy ACLPolicy, stripEmaildomain bool) ([]tailcfg.FilterRule, error) {
|
||||
rules := []tailcfg.FilterRule{}
|
||||
|
||||
for index, acl := range aclPolicy.ACLs {
|
||||
if acl.Action != "accept" {
|
||||
return nil, errInvalidAction
|
||||
}
|
||||
|
||||
srcIPs := []string{}
|
||||
for innerIndex, src := range acl.Sources {
|
||||
srcs, err := generateACLPolicySrcIP(machines, aclPolicy, src, stripEmaildomain)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Msgf("Error parsing ACL %d, Source %d", index, innerIndex)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
srcIPs = append(srcIPs, srcs...)
|
||||
}
|
||||
|
||||
protocols, needsWildcard, err := parseProtocol(acl.Protocol)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Msgf("Error parsing ACL %d. protocol unknown %s", index, acl.Protocol)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
destPorts := []tailcfg.NetPortRange{}
|
||||
for innerIndex, dest := range acl.Destinations {
|
||||
dests, err := generateACLPolicyDest(
|
||||
machines,
|
||||
aclPolicy,
|
||||
dest,
|
||||
needsWildcard,
|
||||
stripEmaildomain,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Msgf("Error parsing ACL %d, Destination %d", index, innerIndex)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
destPorts = append(destPorts, dests...)
|
||||
}
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: srcIPs,
|
||||
DstPorts: destPorts,
|
||||
IPProto: protocols,
|
||||
})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (h *Headscale) generateSSHRules() ([]*tailcfg.SSHRule, error) {
|
||||
rules := []*tailcfg.SSHRule{}
|
||||
|
||||
if h.aclPolicy == nil {
|
||||
return nil, errEmptyPolicy
|
||||
}
|
||||
|
||||
machines, err := h.ListMachines()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acceptAction := tailcfg.SSHAction{
|
||||
Message: "",
|
||||
Reject: false,
|
||||
Accept: true,
|
||||
SessionDuration: 0,
|
||||
AllowAgentForwarding: false,
|
||||
HoldAndDelegate: "",
|
||||
AllowLocalPortForwarding: true,
|
||||
}
|
||||
|
||||
rejectAction := tailcfg.SSHAction{
|
||||
Message: "",
|
||||
Reject: true,
|
||||
Accept: false,
|
||||
SessionDuration: 0,
|
||||
AllowAgentForwarding: false,
|
||||
HoldAndDelegate: "",
|
||||
AllowLocalPortForwarding: false,
|
||||
}
|
||||
|
||||
for index, sshACL := range h.aclPolicy.SSHs {
|
||||
action := rejectAction
|
||||
switch sshACL.Action {
|
||||
case "accept":
|
||||
action = acceptAction
|
||||
case "check":
|
||||
checkAction, err := sshCheckAction(sshACL.CheckPeriod)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Msgf("Error parsing SSH %d, check action with unparsable duration '%s'", index, sshACL.CheckPeriod)
|
||||
} else {
|
||||
action = *checkAction
|
||||
}
|
||||
default:
|
||||
log.Error().
|
||||
Msgf("Error parsing SSH %d, unknown action '%s'", index, sshACL.Action)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
principals := make([]*tailcfg.SSHPrincipal, 0, len(sshACL.Sources))
|
||||
for innerIndex, rawSrc := range sshACL.Sources {
|
||||
expandedSrcs, err := expandAlias(
|
||||
machines,
|
||||
*h.aclPolicy,
|
||||
rawSrc,
|
||||
h.cfg.OIDC.StripEmaildomain,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Msgf("Error parsing SSH %d, Source %d", index, innerIndex)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
for _, expandedSrc := range expandedSrcs {
|
||||
principals = append(principals, &tailcfg.SSHPrincipal{
|
||||
NodeIP: expandedSrc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
userMap := make(map[string]string, len(sshACL.Users))
|
||||
for _, user := range sshACL.Users {
|
||||
userMap[user] = "="
|
||||
}
|
||||
rules = append(rules, &tailcfg.SSHRule{
|
||||
RuleExpires: nil,
|
||||
Principals: principals,
|
||||
SSHUsers: userMap,
|
||||
Action: &action,
|
||||
})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func sshCheckAction(duration string) (*tailcfg.SSHAction, error) {
|
||||
sessionLength, err := time.ParseDuration(duration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tailcfg.SSHAction{
|
||||
Message: "",
|
||||
Reject: false,
|
||||
Accept: true,
|
||||
SessionDuration: sessionLength,
|
||||
AllowAgentForwarding: false,
|
||||
HoldAndDelegate: "",
|
||||
AllowLocalPortForwarding: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateACLPolicySrcIP(
|
||||
machines []Machine,
|
||||
aclPolicy ACLPolicy,
|
||||
src string,
|
||||
stripEmaildomain bool,
|
||||
) ([]string, error) {
|
||||
return expandAlias(machines, aclPolicy, src, stripEmaildomain)
|
||||
}
|
||||
|
||||
func generateACLPolicyDest(
|
||||
machines []Machine,
|
||||
aclPolicy ACLPolicy,
|
||||
dest string,
|
||||
needsWildcard bool,
|
||||
stripEmaildomain bool,
|
||||
) ([]tailcfg.NetPortRange, error) {
|
||||
tokens := strings.Split(dest, ":")
|
||||
if len(tokens) < expectedTokenItems || len(tokens) > 3 {
|
||||
return nil, errInvalidPortFormat
|
||||
}
|
||||
|
||||
var alias string
|
||||
// We can have here stuff like:
|
||||
// git-server:*
|
||||
// 192.168.1.0/24:22
|
||||
// tag:montreal-webserver:80,443
|
||||
// tag:api-server:443
|
||||
// example-host-1:*
|
||||
if len(tokens) == expectedTokenItems {
|
||||
alias = tokens[0]
|
||||
} else {
|
||||
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
|
||||
}
|
||||
|
||||
expanded, err := expandAlias(
|
||||
machines,
|
||||
aclPolicy,
|
||||
alias,
|
||||
stripEmaildomain,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports, err := expandPorts(tokens[len(tokens)-1], needsWildcard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dests := []tailcfg.NetPortRange{}
|
||||
for _, d := range expanded {
|
||||
for _, p := range *ports {
|
||||
pr := tailcfg.NetPortRange{
|
||||
IP: d,
|
||||
Ports: p,
|
||||
}
|
||||
dests = append(dests, pr)
|
||||
}
|
||||
}
|
||||
|
||||
return dests, nil
|
||||
}
|
||||
|
||||
// parseProtocol reads the proto field of the ACL and generates a list of
|
||||
// protocols that will be allowed, following the IANA IP protocol number
|
||||
// https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml
|
||||
//
|
||||
// If the ACL proto field is empty, it allows ICMPv4, ICMPv6, TCP, and UDP,
|
||||
// as per Tailscale behaviour (see tailcfg.FilterRule).
|
||||
//
|
||||
// Also returns a boolean indicating if the protocol
|
||||
// requires all the destinations to use wildcard as port number (only TCP,
|
||||
// UDP and SCTP support specifying ports).
|
||||
func parseProtocol(protocol string) ([]int, bool, error) {
|
||||
switch protocol {
|
||||
case "":
|
||||
return nil, false, nil
|
||||
case "igmp":
|
||||
return []int{protocolIGMP}, true, nil
|
||||
case "ipv4", "ip-in-ip":
|
||||
return []int{protocolIPv4}, true, nil
|
||||
case "tcp":
|
||||
return []int{protocolTCP}, false, nil
|
||||
case "egp":
|
||||
return []int{protocolEGP}, true, nil
|
||||
case "igp":
|
||||
return []int{protocolIGP}, true, nil
|
||||
case "udp":
|
||||
return []int{protocolUDP}, false, nil
|
||||
case "gre":
|
||||
return []int{protocolGRE}, true, nil
|
||||
case "esp":
|
||||
return []int{protocolESP}, true, nil
|
||||
case "ah":
|
||||
return []int{protocolAH}, true, nil
|
||||
case "sctp":
|
||||
return []int{protocolSCTP}, false, nil
|
||||
case "icmp":
|
||||
return []int{protocolICMP, protocolIPv6ICMP}, true, nil
|
||||
|
||||
default:
|
||||
protocolNumber, err := strconv.Atoi(protocol)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
needsWildcard := protocolNumber != protocolTCP &&
|
||||
protocolNumber != protocolUDP &&
|
||||
protocolNumber != protocolSCTP
|
||||
|
||||
return []int{protocolNumber}, needsWildcard, nil
|
||||
}
|
||||
}
|
||||
|
||||
// expandalias has an input of either
|
||||
// - a user
|
||||
// - a group
|
||||
// - a tag
|
||||
// and transform these in IPAddresses.
|
||||
func expandAlias(
|
||||
machines []Machine,
|
||||
aclPolicy ACLPolicy,
|
||||
alias string,
|
||||
stripEmailDomain bool,
|
||||
) ([]string, error) {
|
||||
ips := []string{}
|
||||
if alias == "*" {
|
||||
return []string{"*"}, nil
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("alias", alias).
|
||||
Msg("Expanding")
|
||||
|
||||
if strings.HasPrefix(alias, "group:") {
|
||||
users, err := expandGroup(aclPolicy, alias, stripEmailDomain)
|
||||
if err != nil {
|
||||
return ips, err
|
||||
}
|
||||
for _, n := range users {
|
||||
nodes := filterMachinesByUser(machines, n)
|
||||
for _, node := range nodes {
|
||||
ips = append(ips, node.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(alias, "tag:") {
|
||||
// check for forced tags
|
||||
for _, machine := range machines {
|
||||
if contains(machine.ForcedTags, alias) {
|
||||
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
}
|
||||
|
||||
// find tag owners
|
||||
owners, err := expandTagOwners(aclPolicy, alias, stripEmailDomain)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidTag) {
|
||||
if len(ips) == 0 {
|
||||
return ips, fmt.Errorf(
|
||||
"%w. %v isn't owned by a TagOwner and no forced tags are defined",
|
||||
errInvalidTag,
|
||||
alias,
|
||||
)
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
} else {
|
||||
return ips, err
|
||||
}
|
||||
}
|
||||
|
||||
// filter out machines per tag owner
|
||||
for _, user := range owners {
|
||||
machines := filterMachinesByUser(machines, user)
|
||||
for _, machine := range machines {
|
||||
hi := machine.GetHostInfo()
|
||||
if contains(hi.RequestTags, alias) {
|
||||
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// if alias is a user
|
||||
nodes := filterMachinesByUser(machines, alias)
|
||||
nodes = excludeCorrectlyTaggedNodes(aclPolicy, nodes, alias, stripEmailDomain)
|
||||
|
||||
for _, n := range nodes {
|
||||
ips = append(ips, n.IPAddresses.ToStringSlice()...)
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// if alias is an host
|
||||
if h, ok := aclPolicy.Hosts[alias]; ok {
|
||||
return []string{h.String()}, nil
|
||||
}
|
||||
|
||||
// if alias is an IP
|
||||
ip, err := netip.ParseAddr(alias)
|
||||
if err == nil {
|
||||
return []string{ip.String()}, nil
|
||||
}
|
||||
|
||||
// if alias is an CIDR
|
||||
cidr, err := netip.ParsePrefix(alias)
|
||||
if err == nil {
|
||||
return []string{cidr.String()}, nil
|
||||
}
|
||||
|
||||
log.Warn().Msgf("No IPs found with the alias %v", alias)
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// excludeCorrectlyTaggedNodes will remove from the list of input nodes the ones
|
||||
// that are correctly tagged since they should not be listed as being in the user
|
||||
// we assume in this function that we only have nodes from 1 user.
|
||||
func excludeCorrectlyTaggedNodes(
|
||||
aclPolicy ACLPolicy,
|
||||
nodes []Machine,
|
||||
user string,
|
||||
stripEmailDomain bool,
|
||||
) []Machine {
|
||||
out := []Machine{}
|
||||
tags := []string{}
|
||||
for tag := range aclPolicy.TagOwners {
|
||||
owners, _ := expandTagOwners(aclPolicy, user, stripEmailDomain)
|
||||
ns := append(owners, user)
|
||||
if contains(ns, user) {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
// for each machine if tag is in tags list, don't append it.
|
||||
for _, machine := range nodes {
|
||||
hi := machine.GetHostInfo()
|
||||
|
||||
found := false
|
||||
for _, t := range hi.RequestTags {
|
||||
if contains(tags, t) {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(machine.ForcedTags) > 0 {
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
out = append(out, machine)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func expandPorts(portsStr string, needsWildcard bool) (*[]tailcfg.PortRange, error) {
|
||||
if portsStr == "*" {
|
||||
return &[]tailcfg.PortRange{
|
||||
{First: portRangeBegin, Last: portRangeEnd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if needsWildcard {
|
||||
return nil, errWildcardIsNeeded
|
||||
}
|
||||
|
||||
ports := []tailcfg.PortRange{}
|
||||
for _, portStr := range strings.Split(portsStr, ",") {
|
||||
rang := strings.Split(portStr, "-")
|
||||
switch len(rang) {
|
||||
case 1:
|
||||
port, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports = append(ports, tailcfg.PortRange{
|
||||
First: uint16(port),
|
||||
Last: uint16(port),
|
||||
})
|
||||
|
||||
case expectedTokenItems:
|
||||
start, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
last, err := strconv.ParseUint(rang[1], Base10, BitSize16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports = append(ports, tailcfg.PortRange{
|
||||
First: uint16(start),
|
||||
Last: uint16(last),
|
||||
})
|
||||
|
||||
default:
|
||||
return nil, errInvalidPortFormat
|
||||
}
|
||||
}
|
||||
|
||||
return &ports, nil
|
||||
}
|
||||
|
||||
func filterMachinesByUser(machines []Machine, user string) []Machine {
|
||||
out := []Machine{}
|
||||
for _, machine := range machines {
|
||||
if machine.User.Name == user {
|
||||
out = append(out, machine)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// expandTagOwners will return a list of user. An owner can be either a user or a group
|
||||
// a group cannot be composed of groups.
|
||||
func expandTagOwners(
|
||||
aclPolicy ACLPolicy,
|
||||
tag string,
|
||||
stripEmailDomain bool,
|
||||
) ([]string, error) {
|
||||
var owners []string
|
||||
ows, ok := aclPolicy.TagOwners[tag]
|
||||
if !ok {
|
||||
return []string{}, fmt.Errorf(
|
||||
"%w. %v isn't owned by a TagOwner. Please add one first. https://tailscale.com/kb/1018/acls/#tag-owners",
|
||||
errInvalidTag,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
for _, owner := range ows {
|
||||
if strings.HasPrefix(owner, "group:") {
|
||||
gs, err := expandGroup(aclPolicy, owner, stripEmailDomain)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
owners = append(owners, gs...)
|
||||
} else {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
}
|
||||
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
// expandGroup will return the list of user inside the group
|
||||
// after some validation.
|
||||
func expandGroup(
|
||||
aclPolicy ACLPolicy,
|
||||
group string,
|
||||
stripEmailDomain bool,
|
||||
) ([]string, error) {
|
||||
outGroups := []string{}
|
||||
aclGroups, ok := aclPolicy.Groups[group]
|
||||
if !ok {
|
||||
return []string{}, fmt.Errorf(
|
||||
"group %v isn't registered. %w",
|
||||
group,
|
||||
errInvalidGroup,
|
||||
)
|
||||
}
|
||||
for _, group := range aclGroups {
|
||||
if strings.HasPrefix(group, "group:") {
|
||||
return []string{}, fmt.Errorf(
|
||||
"%w. A group cannot be composed of groups. https://tailscale.com/kb/1018/acls/#groups",
|
||||
errInvalidGroup,
|
||||
)
|
||||
}
|
||||
grp, err := NormalizeToFQDNRules(group, stripEmailDomain)
|
||||
if err != nil {
|
||||
return []string{}, fmt.Errorf(
|
||||
"failed to normalize group %q, err: %w",
|
||||
group,
|
||||
errInvalidGroup,
|
||||
)
|
||||
}
|
||||
outGroups = append(outGroups, grp)
|
||||
}
|
||||
|
||||
return outGroups, nil
|
||||
}
|
||||
1543
acls_test.go
Normal file
1543
acls_test.go
Normal file
File diff suppressed because it is too large
Load Diff
145
acls_types.go
Normal file
145
acls_types.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ACLPolicy represents a Tailscale ACL Policy.
|
||||
type ACLPolicy struct {
|
||||
Groups Groups `json:"groups" yaml:"groups"`
|
||||
Hosts Hosts `json:"hosts" yaml:"hosts"`
|
||||
TagOwners TagOwners `json:"tagOwners" yaml:"tagOwners"`
|
||||
ACLs []ACL `json:"acls" yaml:"acls"`
|
||||
Tests []ACLTest `json:"tests" yaml:"tests"`
|
||||
AutoApprovers AutoApprovers `json:"autoApprovers" yaml:"autoApprovers"`
|
||||
SSHs []SSH `json:"ssh" yaml:"ssh"`
|
||||
}
|
||||
|
||||
// ACL is a basic rule for the ACL Policy.
|
||||
type ACL struct {
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Protocol string `json:"proto" yaml:"proto"`
|
||||
Sources []string `json:"src" yaml:"src"`
|
||||
Destinations []string `json:"dst" yaml:"dst"`
|
||||
}
|
||||
|
||||
// Groups references a series of alias in the ACL rules.
|
||||
type Groups map[string][]string
|
||||
|
||||
// Hosts are alias for IP addresses or subnets.
|
||||
type Hosts map[string]netip.Prefix
|
||||
|
||||
// TagOwners specify what users (users?) are allow to use certain tags.
|
||||
type TagOwners map[string][]string
|
||||
|
||||
// ACLTest is not implemented, but should be use to check if a certain rule is allowed.
|
||||
type ACLTest struct {
|
||||
Source string `json:"src" yaml:"src"`
|
||||
Accept []string `json:"accept" yaml:"accept"`
|
||||
Deny []string `json:"deny,omitempty" yaml:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// AutoApprovers specify which users (users?), groups or tags have their advertised routes
|
||||
// or exit node status automatically enabled.
|
||||
type AutoApprovers struct {
|
||||
Routes map[string][]string `json:"routes" yaml:"routes"`
|
||||
ExitNode []string `json:"exitNode" yaml:"exitNode"`
|
||||
}
|
||||
|
||||
// SSH controls who can ssh into which machines.
|
||||
type SSH struct {
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Sources []string `json:"src" yaml:"src"`
|
||||
Destinations []string `json:"dst" yaml:"dst"`
|
||||
Users []string `json:"users" yaml:"users"`
|
||||
CheckPeriod string `json:"checkPeriod,omitempty" yaml:"checkPeriod,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows to parse the Hosts directly into netip objects.
|
||||
func (hosts *Hosts) UnmarshalJSON(data []byte) error {
|
||||
newHosts := Hosts{}
|
||||
hostIPPrefixMap := make(map[string]string)
|
||||
ast, err := hujson.Parse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.Standardize()
|
||||
data = ast.Pack()
|
||||
err = json.Unmarshal(data, &hostIPPrefixMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for host, prefixStr := range hostIPPrefixMap {
|
||||
if !strings.Contains(prefixStr, "/") {
|
||||
prefixStr += "/32"
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(prefixStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newHosts[host] = prefix
|
||||
}
|
||||
*hosts = newHosts
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML allows to parse the Hosts directly into netip objects.
|
||||
func (hosts *Hosts) UnmarshalYAML(data []byte) error {
|
||||
newHosts := Hosts{}
|
||||
hostIPPrefixMap := make(map[string]string)
|
||||
|
||||
err := yaml.Unmarshal(data, &hostIPPrefixMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for host, prefixStr := range hostIPPrefixMap {
|
||||
prefix, err := netip.ParsePrefix(prefixStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newHosts[host] = prefix
|
||||
}
|
||||
*hosts = newHosts
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsZero is perhaps a bit naive here.
|
||||
func (policy ACLPolicy) IsZero() bool {
|
||||
if len(policy.Groups) == 0 && len(policy.Hosts) == 0 && len(policy.ACLs) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns the list of autoApproving users, groups or tags for a given IPPrefix.
|
||||
func (autoApprovers *AutoApprovers) GetRouteApprovers(
|
||||
prefix netip.Prefix,
|
||||
) ([]string, error) {
|
||||
if prefix.Bits() == 0 {
|
||||
return autoApprovers.ExitNode, nil // 0.0.0.0/0, ::/0 or equivalent
|
||||
}
|
||||
|
||||
approverAliases := []string{}
|
||||
|
||||
for autoApprovedPrefix, autoApproverAliases := range autoApprovers.Routes {
|
||||
autoApprovedPrefix, err := netip.ParsePrefix(autoApprovedPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if prefix.Bits() >= autoApprovedPrefix.Bits() &&
|
||||
autoApprovedPrefix.Contains(prefix.Masked().Addr()) {
|
||||
approverAliases = append(approverAliases, autoApproverAliases...)
|
||||
}
|
||||
}
|
||||
|
||||
return approverAliases, nil
|
||||
}
|
||||
168
api.go
Normal file
168
api.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/zerolog/log"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO(juan): remove this once https://github.com/juanfont/headscale/issues/727 is fixed.
|
||||
registrationHoldoff = time.Second * 5
|
||||
reservedResponseHeaderSize = 4
|
||||
RegisterMethodAuthKey = "authkey"
|
||||
RegisterMethodOIDC = "oidc"
|
||||
RegisterMethodCLI = "cli"
|
||||
ErrRegisterMethodCLIDoesNotSupportExpire = Error(
|
||||
"machines registered with CLI does not support expire",
|
||||
)
|
||||
)
|
||||
|
||||
func (h *Headscale) HealthHandler(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
respond := func(err error) {
|
||||
writer.Header().Set("Content-Type", "application/health+json; charset=utf-8")
|
||||
|
||||
res := struct {
|
||||
Status string `json:"status"`
|
||||
}{
|
||||
Status: "pass",
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
log.Error().Caller().Err(err).Msg("health check failed")
|
||||
res.Status = "fail"
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
log.Error().Caller().Err(err).Msg("marshal failed")
|
||||
}
|
||||
_, err = writer.Write(buf)
|
||||
if err != nil {
|
||||
log.Error().Caller().Err(err).Msg("write failed")
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.pingDB(req.Context()); err != nil {
|
||||
respond(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
respond(nil)
|
||||
}
|
||||
|
||||
type registerWebAPITemplateConfig struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
var registerWebAPITemplate = template.Must(
|
||||
template.New("registerweb").Parse(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Registration - Headscale</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>headscale</h1>
|
||||
<h2>Machine registration</h2>
|
||||
<p>
|
||||
Run the command below in the headscale server to add this machine to your network:
|
||||
</p>
|
||||
<pre><code>headscale nodes register --user USERNAME --key {{.Key}}</code></pre>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
// RegisterWebAPI shows a simple message in the browser to point to the CLI
|
||||
// Listens in /register/:nkey.
|
||||
//
|
||||
// This is not part of the Tailscale control API, as we could send whatever URL
|
||||
// in the RegisterResponse.AuthURL field.
|
||||
func (h *Headscale) RegisterWebAPI(
|
||||
writer http.ResponseWriter,
|
||||
req *http.Request,
|
||||
) {
|
||||
vars := mux.Vars(req)
|
||||
nodeKeyStr, ok := vars["nkey"]
|
||||
|
||||
if !NodePublicKeyRegex.Match([]byte(nodeKeyStr)) {
|
||||
log.Warn().Str("node_key", nodeKeyStr).Msg("Invalid node key passed to registration url")
|
||||
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
_, err := writer.Write([]byte("Unauthorized"))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// We need to make sure we dont open for XSS style injections, if the parameter that
|
||||
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
|
||||
// the template and log an error.
|
||||
var nodeKey key.NodePublic
|
||||
err := nodeKey.UnmarshalText(
|
||||
[]byte(NodePublicKeyEnsurePrefix(nodeKeyStr)),
|
||||
)
|
||||
|
||||
if !ok || nodeKeyStr == "" || err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to parse incoming nodekey")
|
||||
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, err := writer.Write([]byte("Wrong params"))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var content bytes.Buffer
|
||||
if err := registerWebAPITemplate.Execute(&content, registerWebAPITemplateConfig{
|
||||
Key: nodeKeyStr,
|
||||
}); err != nil {
|
||||
log.Error().
|
||||
Str("func", "RegisterWebAPI").
|
||||
Err(err).
|
||||
Msg("Could not render register web API template")
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, err = writer.Write([]byte("Could not render register web API template"))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, err = writer.Write(content.Bytes())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
}
|
||||
81
api_common.go
Normal file
81
api_common.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func (h *Headscale) generateMapResponse(
|
||||
mapRequest tailcfg.MapRequest,
|
||||
machine *Machine,
|
||||
) (*tailcfg.MapResponse, error) {
|
||||
log.Trace().
|
||||
Str("func", "generateMapResponse").
|
||||
Str("machine", mapRequest.Hostinfo.Hostname).
|
||||
Msg("Creating Map response")
|
||||
node, err := h.toNode(*machine, h.cfg.BaseDomain, h.cfg.DNSConfig)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("func", "generateMapResponse").
|
||||
Err(err).
|
||||
Msg("Cannot convert to node")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peers, err := h.getValidPeers(machine)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("func", "generateMapResponse").
|
||||
Err(err).
|
||||
Msg("Cannot fetch peers")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profiles := h.getMapResponseUserProfiles(*machine, peers)
|
||||
|
||||
nodePeers, err := h.toNodes(peers, h.cfg.BaseDomain, h.cfg.DNSConfig)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Str("func", "generateMapResponse").
|
||||
Err(err).
|
||||
Msg("Failed to convert peers to Tailscale nodes")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dnsConfig := getMapResponseDNSConfig(
|
||||
h.cfg.DNSConfig,
|
||||
h.cfg.BaseDomain,
|
||||
*machine,
|
||||
peers,
|
||||
)
|
||||
|
||||
resp := tailcfg.MapResponse{
|
||||
KeepAlive: false,
|
||||
Node: node,
|
||||
Peers: nodePeers,
|
||||
DNSConfig: dnsConfig,
|
||||
Domain: h.cfg.BaseDomain,
|
||||
PacketFilter: h.aclRules,
|
||||
SSHPolicy: h.sshPolicy,
|
||||
DERPMap: h.DERPMap,
|
||||
UserProfiles: profiles,
|
||||
Debug: &tailcfg.Debug{
|
||||
DisableLogTail: !h.cfg.LogTail.Enabled,
|
||||
RandomizeClientPort: h.cfg.RandomizeClientPort,
|
||||
},
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
Str("func", "generateMapResponse").
|
||||
Str("machine", mapRequest.Hostinfo.Hostname).
|
||||
// Interface("payload", resp).
|
||||
Msgf("Generated map response: %s", tailMapResponseToString(resp))
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
157
api_key.go
Normal file
157
api_key.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefixLength = 7
|
||||
apiKeyLength = 32
|
||||
|
||||
ErrAPIKeyFailedToParse = Error("Failed to parse ApiKey")
|
||||
)
|
||||
|
||||
// APIKey describes the datamodel for API keys used to remotely authenticate with
|
||||
// headscale.
|
||||
type APIKey struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
Prefix string `gorm:"uniqueIndex"`
|
||||
Hash []byte
|
||||
|
||||
CreatedAt *time.Time
|
||||
Expiration *time.Time
|
||||
LastSeen *time.Time
|
||||
}
|
||||
|
||||
// CreateAPIKey creates a new ApiKey in a user, and returns it.
|
||||
func (h *Headscale) CreateAPIKey(
|
||||
expiration *time.Time,
|
||||
) (string, *APIKey, error) {
|
||||
prefix, err := GenerateRandomStringURLSafe(apiPrefixLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
toBeHashed, err := GenerateRandomStringURLSafe(apiKeyLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Key to return to user, this will only be visible _once_
|
||||
keyStr := prefix + "." + toBeHashed
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
key := APIKey{
|
||||
Prefix: prefix,
|
||||
Hash: hash,
|
||||
Expiration: expiration,
|
||||
}
|
||||
|
||||
if err := h.db.Save(&key).Error; err != nil {
|
||||
return "", nil, fmt.Errorf("failed to save API key to database: %w", err)
|
||||
}
|
||||
|
||||
return keyStr, &key, nil
|
||||
}
|
||||
|
||||
// ListAPIKeys returns the list of ApiKeys for a user.
|
||||
func (h *Headscale) ListAPIKeys() ([]APIKey, error) {
|
||||
keys := []APIKey{}
|
||||
if err := h.db.Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetAPIKey returns a ApiKey for a given key.
|
||||
func (h *Headscale) GetAPIKey(prefix string) (*APIKey, error) {
|
||||
key := APIKey{}
|
||||
if result := h.db.First(&key, "prefix = ?", prefix); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// GetAPIKeyByID returns a ApiKey for a given id.
|
||||
func (h *Headscale) GetAPIKeyByID(id uint64) (*APIKey, error) {
|
||||
key := APIKey{}
|
||||
if result := h.db.Find(&APIKey{ID: id}).First(&key); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DestroyAPIKey destroys a ApiKey. Returns error if the ApiKey
|
||||
// does not exist.
|
||||
func (h *Headscale) DestroyAPIKey(key APIKey) error {
|
||||
if result := h.db.Unscoped().Delete(key); result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireAPIKey marks a ApiKey as expired.
|
||||
func (h *Headscale) ExpireAPIKey(key *APIKey) error {
|
||||
if err := h.db.Model(&key).Update("Expiration", time.Now()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Headscale) ValidateAPIKey(keyStr string) (bool, error) {
|
||||
prefix, hash, found := strings.Cut(keyStr, ".")
|
||||
if !found {
|
||||
return false, ErrAPIKeyFailedToParse
|
||||
}
|
||||
|
||||
key, err := h.GetAPIKey(prefix)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to validate api key: %w", err)
|
||||
}
|
||||
|
||||
if key.Expiration.Before(time.Now()) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(key.Hash, []byte(hash)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (key *APIKey) toProto() *v1.ApiKey {
|
||||
protoKey := v1.ApiKey{
|
||||
Id: key.ID,
|
||||
Prefix: key.Prefix,
|
||||
}
|
||||
|
||||
if key.Expiration != nil {
|
||||
protoKey.Expiration = timestamppb.New(*key.Expiration)
|
||||
}
|
||||
|
||||
if key.CreatedAt != nil {
|
||||
protoKey.CreatedAt = timestamppb.New(*key.CreatedAt)
|
||||
}
|
||||
|
||||
if key.LastSeen != nil {
|
||||
protoKey.LastSeen = timestamppb.New(*key.LastSeen)
|
||||
}
|
||||
|
||||
return &protoKey
|
||||
}
|
||||
89
api_key_test.go
Normal file
89
api_key_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
func (*Suite) TestCreateAPIKey(c *check.C) {
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
// Did we get a valid key?
|
||||
c.Assert(apiKey.Prefix, check.NotNil)
|
||||
c.Assert(apiKey.Hash, check.NotNil)
|
||||
c.Assert(apiKeyStr, check.Not(check.Equals), "")
|
||||
|
||||
_, err = app.ListAPIKeys()
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
keys, err := app.ListAPIKeys()
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(len(keys), check.Equals, 1)
|
||||
}
|
||||
|
||||
func (*Suite) TestAPIKeyDoesNotExist(c *check.C) {
|
||||
key, err := app.GetAPIKey("does-not-exist")
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(key, check.IsNil)
|
||||
}
|
||||
|
||||
func (*Suite) TestValidateAPIKeyOk(c *check.C) {
|
||||
nowPlus2 := time.Now().Add(2 * time.Hour)
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(&nowPlus2)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
valid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(valid, check.Equals, true)
|
||||
}
|
||||
|
||||
func (*Suite) TestValidateAPIKeyNotOk(c *check.C) {
|
||||
nowMinus2 := time.Now().Add(time.Duration(-2) * time.Hour)
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(&nowMinus2)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
valid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(valid, check.Equals, false)
|
||||
|
||||
now := time.Now()
|
||||
apiKeyStrNow, apiKey, err := app.CreateAPIKey(&now)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
validNow, err := app.ValidateAPIKey(apiKeyStrNow)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(validNow, check.Equals, false)
|
||||
|
||||
validSilly, err := app.ValidateAPIKey("nota.validkey")
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(validSilly, check.Equals, false)
|
||||
|
||||
validWithErr, err := app.ValidateAPIKey("produceerrorkey")
|
||||
c.Assert(err, check.NotNil)
|
||||
c.Assert(validWithErr, check.Equals, false)
|
||||
}
|
||||
|
||||
func (*Suite) TestExpireAPIKey(c *check.C) {
|
||||
nowPlus2 := time.Now().Add(2 * time.Hour)
|
||||
apiKeyStr, apiKey, err := app.CreateAPIKey(&nowPlus2)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey, check.NotNil)
|
||||
|
||||
valid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(valid, check.Equals, true)
|
||||
|
||||
err = app.ExpireAPIKey(apiKey)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(apiKey.Expiration, check.NotNil)
|
||||
|
||||
notValid, err := app.ValidateAPIKey(apiKeyStr)
|
||||
c.Assert(err, check.IsNil)
|
||||
c.Assert(notValid, check.Equals, false)
|
||||
}
|
||||
61
app_test.go
Normal file
61
app_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
check.TestingT(t)
|
||||
}
|
||||
|
||||
var _ = check.Suite(&Suite{})
|
||||
|
||||
type Suite struct{}
|
||||
|
||||
var (
|
||||
tmpDir string
|
||||
app Headscale
|
||||
)
|
||||
|
||||
func (s *Suite) SetUpTest(c *check.C) {
|
||||
s.ResetDB(c)
|
||||
}
|
||||
|
||||
func (s *Suite) TearDownTest(c *check.C) {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
func (s *Suite) ResetDB(c *check.C) {
|
||||
if len(tmpDir) != 0 {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "autoygg-client-test")
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
cfg := Config{
|
||||
IPPrefixes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.27.0.0/23"),
|
||||
},
|
||||
}
|
||||
|
||||
app = Headscale{
|
||||
cfg: &cfg,
|
||||
dbType: "sqlite3",
|
||||
dbString: tmpDir + "/headscale_test.db",
|
||||
}
|
||||
err = app.initDB()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
db, err := app.openDB()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
app.db = db
|
||||
}
|
||||
114
cmd/gh-action-integration-generator/main.go
Normal file
114
cmd/gh-action-integration-generator/main.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
//go:generate go run ./main.go
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var (
|
||||
jobFileNameTemplate = `test-integration-v2-%s.yaml`
|
||||
jobTemplate = template.Must(template.New("jobTemplate").Parse(`
|
||||
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
|
||||
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/
|
||||
|
||||
name: Integration Test v2 - {{.Name}}
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v34
|
||||
with:
|
||||
files: |
|
||||
*.nix
|
||||
go.*
|
||||
**/*.go
|
||||
integration_test/
|
||||
config-example.yaml
|
||||
|
||||
- uses: cachix/install-nix-action@v16
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
- name: Run general integration tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
nix develop --command -- docker run \
|
||||
--tty --rm \
|
||||
--volume ~/.cache/hs-integration-go:/go \
|
||||
--name headscale-test-suite \
|
||||
--volume $PWD:$PWD -w $PWD/integration \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||
golang:1 \
|
||||
go test ./... \
|
||||
-tags ts2019 \
|
||||
-failfast \
|
||||
-timeout 120m \
|
||||
-parallel 1 \
|
||||
-run "^{{.Name}}$"
|
||||
`))
|
||||
)
|
||||
|
||||
const workflowFilePerm = 0o600
|
||||
|
||||
func main() {
|
||||
type testConfig struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// TODO(kradalby): automatic fetch tests at runtime
|
||||
tests := []string{
|
||||
"TestAuthKeyLogoutAndRelogin",
|
||||
"TestAuthWebFlowAuthenticationPingAll",
|
||||
"TestAuthWebFlowLogoutAndRelogin",
|
||||
"TestCreateTailscale",
|
||||
"TestEnablingRoutes",
|
||||
"TestHeadscale",
|
||||
"TestUserCommand",
|
||||
"TestOIDCAuthenticationPingAll",
|
||||
"TestOIDCExpireNodes",
|
||||
"TestPingAllByHostname",
|
||||
"TestPingAllByIP",
|
||||
"TestPreAuthKeyCommand",
|
||||
"TestPreAuthKeyCommandReusableEphemeral",
|
||||
"TestPreAuthKeyCommandWithoutExpiry",
|
||||
"TestResolveMagicDNS",
|
||||
"TestSSHIsBlockedInACL",
|
||||
"TestSSHMultipleUsersAllToAll",
|
||||
"TestSSHNoSSHConfigured",
|
||||
"TestSSHOneUserAllToAll",
|
||||
"TestSSUserOnlyIsolation",
|
||||
"TestTaildrop",
|
||||
"TestTailscaleNodesJoiningHeadcale",
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
var content bytes.Buffer
|
||||
|
||||
if err := jobTemplate.Execute(&content, testConfig{
|
||||
Name: test,
|
||||
}); err != nil {
|
||||
log.Fatalf("failed to render template: %s", err)
|
||||
}
|
||||
|
||||
path := "../../.github/workflows/" + fmt.Sprintf(jobFileNameTemplate, test)
|
||||
|
||||
err := os.WriteFile(path, content.Bytes(), workflowFilePerm)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to write github job: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultAPIKeyExpiry is 90 days.
|
||||
// 90 days.
|
||||
DefaultAPIKeyExpiry = "90d"
|
||||
)
|
||||
|
||||
@@ -26,12 +29,11 @@ func init() {
|
||||
apiKeysCmd.AddCommand(createAPIKeyCmd)
|
||||
|
||||
expireAPIKeyCmd.Flags().StringP("prefix", "p", "", "ApiKey prefix")
|
||||
expireAPIKeyCmd.Flags().Uint64P("id", "i", 0, "ApiKey ID")
|
||||
err := expireAPIKeyCmd.MarkFlagRequired("prefix")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
apiKeysCmd.AddCommand(expireAPIKeyCmd)
|
||||
|
||||
deleteAPIKeyCmd.Flags().StringP("prefix", "p", "", "ApiKey prefix")
|
||||
deleteAPIKeyCmd.Flags().Uint64P("id", "i", 0, "ApiKey ID")
|
||||
apiKeysCmd.AddCommand(deleteAPIKeyCmd)
|
||||
}
|
||||
|
||||
var apiKeysCmd = &cobra.Command{
|
||||
@@ -44,35 +46,61 @@ var listAPIKeys = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the Api keys for headscale",
|
||||
Aliases: []string{"ls", "show"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{})
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ListApiKeysRequest{}
|
||||
|
||||
response, err := client.ListApiKeys(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing api keys: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting the list of keys: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printListOutput(cmd, response.GetApiKeys(), func() error {
|
||||
tableData := pterm.TableData{
|
||||
{"ID", "Prefix", "Expiration", "Created"},
|
||||
if output != "" {
|
||||
SuccessOutput(response.ApiKeys, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData := pterm.TableData{
|
||||
{"ID", "Prefix", "Expiration", "Created"},
|
||||
}
|
||||
for _, key := range response.ApiKeys {
|
||||
expiration := "-"
|
||||
|
||||
if key.GetExpiration() != nil {
|
||||
expiration = ColourTime(key.Expiration.AsTime())
|
||||
}
|
||||
|
||||
for _, key := range response.GetApiKeys() {
|
||||
expiration := "-"
|
||||
tableData = append(tableData, []string{
|
||||
strconv.FormatUint(key.GetId(), headscale.Base10),
|
||||
key.GetPrefix(),
|
||||
expiration,
|
||||
key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
|
||||
})
|
||||
|
||||
if key.GetExpiration() != nil {
|
||||
expiration = ColourTime(key.GetExpiration().AsTime())
|
||||
}
|
||||
}
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
tableData = append(tableData, []string{
|
||||
strconv.FormatUint(key.GetId(), util.Base10),
|
||||
key.GetPrefix(),
|
||||
expiration,
|
||||
key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
|
||||
})
|
||||
}
|
||||
|
||||
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
})
|
||||
}),
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var createAPIKeyCmd = &cobra.Command{
|
||||
@@ -83,79 +111,91 @@ Creates a new Api key, the Api key is only visible on creation
|
||||
and cannot be retrieved again.
|
||||
If you loose a key, create a new one and revoke (expire) the old one.`,
|
||||
Aliases: []string{"c", "new"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
expiration, err := expirationFromFlag(cmd)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
log.Trace().
|
||||
Msg("Preparing to create ApiKey")
|
||||
|
||||
request := &v1.CreateApiKeyRequest{}
|
||||
|
||||
durationStr, _ := cmd.Flags().GetString("expiration")
|
||||
|
||||
duration, err := model.ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
return err
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Could not parse duration: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
response, err := client.CreateApiKey(ctx, &v1.CreateApiKeyRequest{
|
||||
Expiration: expiration,
|
||||
})
|
||||
expiration := time.Now().UTC().Add(time.Duration(duration))
|
||||
|
||||
log.Trace().
|
||||
Dur("expiration", time.Duration(duration)).
|
||||
Msg("expiration has been set")
|
||||
|
||||
request.Expiration = timestamppb.New(expiration)
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
response, err := client.CreateApiKey(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating api key: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot create Api Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(cmd, response.GetApiKey(), response.GetApiKey())
|
||||
}),
|
||||
}
|
||||
|
||||
// apiKeyIDOrPrefix reads --id and --prefix from cmd and validates that
|
||||
// exactly one is provided.
|
||||
func apiKeyIDOrPrefix(cmd *cobra.Command) (uint64, string, error) {
|
||||
id, _ := cmd.Flags().GetUint64("id")
|
||||
prefix, _ := cmd.Flags().GetString("prefix")
|
||||
|
||||
switch {
|
||||
case id == 0 && prefix == "":
|
||||
return 0, "", fmt.Errorf("either --id or --prefix must be provided: %w", errMissingParameter)
|
||||
case id != 0 && prefix != "":
|
||||
return 0, "", fmt.Errorf("only one of --id or --prefix can be provided: %w", errMissingParameter)
|
||||
}
|
||||
|
||||
return id, prefix, nil
|
||||
SuccessOutput(response.ApiKey, response.ApiKey, output)
|
||||
},
|
||||
}
|
||||
|
||||
var expireAPIKeyCmd = &cobra.Command{
|
||||
Use: "expire",
|
||||
Short: "Expire an ApiKey",
|
||||
Aliases: []string{"revoke", "exp", "e"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
id, prefix, err := apiKeyIDOrPrefix(cmd)
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
prefix, err := cmd.Flags().GetString("prefix")
|
||||
if err != nil {
|
||||
return err
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting prefix from CLI flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
response, err := client.ExpireApiKey(ctx, &v1.ExpireApiKeyRequest{
|
||||
Id: id,
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ExpireApiKeyRequest{
|
||||
Prefix: prefix,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("expiring api key: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "Key expired")
|
||||
}),
|
||||
}
|
||||
|
||||
var deleteAPIKeyCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete an ApiKey",
|
||||
Aliases: []string{"remove", "del"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
id, prefix, err := apiKeyIDOrPrefix(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := client.DeleteApiKey(ctx, &v1.DeleteApiKeyRequest{
|
||||
Id: id,
|
||||
Prefix: prefix,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting api key: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "Key deleted")
|
||||
}),
|
||||
response, err := client.ExpireApiKey(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot expire Api Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response, "Key expired", output)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
authRegisterCmd.Flags().StringP("user", "u", "", "User")
|
||||
authRegisterCmd.Flags().String("auth-id", "", "Auth ID")
|
||||
mustMarkRequired(authRegisterCmd, "user", "auth-id")
|
||||
authCmd.AddCommand(authRegisterCmd)
|
||||
|
||||
authApproveCmd.Flags().String("auth-id", "", "Auth ID")
|
||||
mustMarkRequired(authApproveCmd, "auth-id")
|
||||
authCmd.AddCommand(authApproveCmd)
|
||||
|
||||
authRejectCmd.Flags().String("auth-id", "", "Auth ID")
|
||||
mustMarkRequired(authRejectCmd, "auth-id")
|
||||
authCmd.AddCommand(authRejectCmd)
|
||||
}
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage node authentication and approval",
|
||||
}
|
||||
|
||||
var authRegisterCmd = &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Register a node to your network",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
user, _ := cmd.Flags().GetString("user")
|
||||
authID, _ := cmd.Flags().GetString("auth-id")
|
||||
|
||||
request := &v1.AuthRegisterRequest{
|
||||
AuthId: authID,
|
||||
User: user,
|
||||
}
|
||||
|
||||
response, err := client.AuthRegister(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registering node: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(
|
||||
cmd,
|
||||
response.GetNode(),
|
||||
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()))
|
||||
}),
|
||||
}
|
||||
|
||||
var authApproveCmd = &cobra.Command{
|
||||
Use: "approve",
|
||||
Short: "Approve a pending authentication request",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
authID, _ := cmd.Flags().GetString("auth-id")
|
||||
|
||||
request := &v1.AuthApproveRequest{
|
||||
AuthId: authID,
|
||||
}
|
||||
|
||||
response, err := client.AuthApprove(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("approving auth request: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "Auth request approved")
|
||||
}),
|
||||
}
|
||||
|
||||
var authRejectCmd = &cobra.Command{
|
||||
Use: "reject",
|
||||
Short: "Reject a pending authentication request",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
authID, _ := cmd.Flags().GetString("auth-id")
|
||||
|
||||
request := &v1.AuthRejectRequest{
|
||||
AuthId: authID,
|
||||
}
|
||||
|
||||
response, err := client.AuthReject(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rejecting auth request: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "Auth request rejected")
|
||||
}),
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(configTestCmd)
|
||||
}
|
||||
|
||||
var configTestCmd = &cobra.Command{
|
||||
Use: "configtest",
|
||||
Short: "Test the configuration.",
|
||||
Long: "Run a test of the configuration and exit.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := newHeadscaleServerWithConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,22 +1,42 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
errPreAuthKeyMalformed = Error("key is malformed. expected 64 hex characters with `nodekey` prefix")
|
||||
)
|
||||
|
||||
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
|
||||
type Error string
|
||||
|
||||
func (e Error) Error() string { return string(e) }
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
|
||||
createNodeCmd.Flags().StringP("name", "", "", "Name")
|
||||
createNodeCmd.Flags().StringP("user", "u", "", "User")
|
||||
err := createNodeCmd.MarkFlagRequired("name")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
createNodeCmd.Flags().StringP("user", "n", "", "User")
|
||||
err = createNodeCmd.MarkFlagRequired("user")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
createNodeCmd.Flags().StringP("key", "k", "", "Key")
|
||||
mustMarkRequired(createNodeCmd, "name", "user", "key")
|
||||
|
||||
err = createNodeCmd.MarkFlagRequired("key")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
createNodeCmd.Flags().
|
||||
StringSliceP("route", "r", []string{}, "List (or repeated flags) of routes to advertise")
|
||||
|
||||
@@ -31,31 +51,82 @@ var debugCmd = &cobra.Command{
|
||||
|
||||
var createNodeCmd = &cobra.Command{
|
||||
Use: "create-node",
|
||||
Short: "Create a node that can be registered with `auth register <>` command",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
user, _ := cmd.Flags().GetString("user")
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
registrationID, _ := cmd.Flags().GetString("key")
|
||||
Short: "Create a node (machine) that can be registered with `nodes register <>` command",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
_, err := types.AuthIDFromString(registrationID)
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing machine key: %w", err)
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
routes, _ := cmd.Flags().GetStringSlice("route")
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.DebugCreateNodeRequest{
|
||||
Key: registrationID,
|
||||
name, err := cmd.Flags().GetString("name")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting node from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
machineKey, err := cmd.Flags().GetString("key")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting key from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
if !headscale.NodePublicKeyRegex.Match([]byte(machineKey)) {
|
||||
err = errPreAuthKeyMalformed
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := cmd.Flags().GetStringSlice("route")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting routes from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
request := &v1.DebugCreateMachineRequest{
|
||||
Key: machineKey,
|
||||
Name: name,
|
||||
User: user,
|
||||
Routes: routes,
|
||||
}
|
||||
|
||||
response, err := client.DebugCreateNode(ctx, request)
|
||||
response, err := client.DebugCreateMachine(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating node: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot create machine: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(cmd, response.GetNode(), "Node created")
|
||||
}),
|
||||
SuccessOutput(response.Machine, "Machine created", output)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ var dumpConfigCmd = &cobra.Command{
|
||||
Use: "dumpConfig",
|
||||
Short: "dump current config to /etc/headscale/config.dump.yaml, integration test only",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := viper.WriteConfigAs("/etc/headscale/config.dump.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dumping config: %w", err)
|
||||
}
|
||||
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := viper.WriteConfigAs("/etc/headscale/config.dump.yaml")
|
||||
if err != nil {
|
||||
//nolint
|
||||
fmt.Println("Failed to dump config")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -21,17 +21,22 @@ var generateCmd = &cobra.Command{
|
||||
var generatePrivateKeyCmd = &cobra.Command{
|
||||
Use: "private-key",
|
||||
Short: "Generate a private key for the headscale server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
machineKey := key.NewMachine()
|
||||
|
||||
machineKeyStr, err := machineKey.MarshalText()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling machine key: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting machine key from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
return printOutput(cmd, map[string]string{
|
||||
SuccessOutput(map[string]string{
|
||||
"private_key": string(machineKeyStr),
|
||||
},
|
||||
string(machineKeyStr))
|
||||
string(machineKeyStr), output)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(healthCmd)
|
||||
}
|
||||
|
||||
var healthCmd = &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check the health of the Headscale server",
|
||||
Long: "Check the health of the Headscale server. This command will return an exit code of 0 if the server is healthy, or 1 if it is not.",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
response, err := client.Health(ctx, &v1.HealthRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking health: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "")
|
||||
}),
|
||||
}
|
||||
@@ -1,31 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/oauth2-proxy/mockoidc"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
|
||||
type Error string
|
||||
|
||||
func (e Error) Error() string { return string(e) }
|
||||
|
||||
const (
|
||||
errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined")
|
||||
errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined")
|
||||
errMockOidcPortNotDefined = Error("MOCKOIDC_PORT not defined")
|
||||
errMockOidcUsersNotDefined = Error("MOCKOIDC_USERS not defined")
|
||||
refreshTTL = 60 * time.Minute
|
||||
)
|
||||
|
||||
@@ -39,13 +29,12 @@ var mockOidcCmd = &cobra.Command{
|
||||
Use: "mockoidc",
|
||||
Short: "Runs a mock OIDC server for testing",
|
||||
Long: "This internal command runs a OpenID Connect for testing purposes",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := mockOIDC()
|
||||
if err != nil {
|
||||
return fmt.Errorf("running mock OIDC server: %w", err)
|
||||
log.Error().Err(err).Msgf("Error running mock OIDC server")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -54,59 +43,40 @@ func mockOIDC() error {
|
||||
if clientID == "" {
|
||||
return errMockOidcClientIDNotDefined
|
||||
}
|
||||
|
||||
clientSecret := os.Getenv("MOCKOIDC_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
return errMockOidcClientSecretNotDefined
|
||||
}
|
||||
|
||||
addrStr := os.Getenv("MOCKOIDC_ADDR")
|
||||
if addrStr == "" {
|
||||
return errMockOidcPortNotDefined
|
||||
}
|
||||
|
||||
portStr := os.Getenv("MOCKOIDC_PORT")
|
||||
if portStr == "" {
|
||||
return errMockOidcPortNotDefined
|
||||
}
|
||||
|
||||
accessTTLOverride := os.Getenv("MOCKOIDC_ACCESS_TTL")
|
||||
if accessTTLOverride != "" {
|
||||
newTTL, err := time.ParseDuration(accessTTLOverride)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessTTL = newTTL
|
||||
}
|
||||
|
||||
userStr := os.Getenv("MOCKOIDC_USERS")
|
||||
if userStr == "" {
|
||||
return errMockOidcUsersNotDefined
|
||||
}
|
||||
|
||||
var users []mockoidc.MockUser
|
||||
|
||||
err := json.Unmarshal([]byte(userStr), &users)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshalling users: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Interface(zf.Users, users).Msg("loading users from JSON")
|
||||
|
||||
log.Info().Msgf("access token TTL: %s", accessTTL)
|
||||
log.Info().Msgf("Access token TTL: %s", accessTTL)
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mock, err := getMockOIDC(clientID, clientSecret, users)
|
||||
mock, err := getMockOIDC(clientID, clientSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := new(net.ListenConfig).Listen(context.Background(), "tcp", fmt.Sprintf("%s:%d", addrStr, port))
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", addrStr, port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,28 +85,20 @@ func mockOIDC() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Msgf("mock OIDC server listening on %s", listener.Addr().String())
|
||||
log.Info().Msgf("issuer: %s", mock.Issuer())
|
||||
|
||||
log.Info().Msgf("Mock OIDC server listening on %s", listener.Addr().String())
|
||||
log.Info().Msgf("Issuer: %s", mock.Issuer())
|
||||
c := make(chan struct{})
|
||||
<-c
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMockOIDC(clientID string, clientSecret string, users []mockoidc.MockUser) (*mockoidc.MockOIDC, error) {
|
||||
func getMockOIDC(clientID string, clientSecret string) (*mockoidc.MockOIDC, error) {
|
||||
keypair, err := mockoidc.NewKeypair(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userQueue := mockoidc.UserQueue{}
|
||||
|
||||
for _, user := range users {
|
||||
userQueue.Push(&user)
|
||||
}
|
||||
|
||||
mock := mockoidc.MockOIDC{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
@@ -145,20 +107,9 @@ func getMockOIDC(clientID string, clientSecret string, users []mockoidc.MockUser
|
||||
CodeChallengeMethodsSupported: []string{"plain", "S256"},
|
||||
Keypair: keypair,
|
||||
SessionStore: mockoidc.NewSessionStore(),
|
||||
UserQueue: &userQueue,
|
||||
UserQueue: &mockoidc.UserQueue{},
|
||||
ErrorQueue: &mockoidc.ErrorQueue{},
|
||||
}
|
||||
|
||||
_ = mock.AddMiddleware(func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info().Msgf("request: %+v", r)
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if r.Response != nil {
|
||||
log.Info().Msgf("response: %+v", r.Response)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return &mock, nil
|
||||
}
|
||||
|
||||
@@ -1,312 +1,469 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
survey "github.com/AlecAivazis/survey/v2"
|
||||
"github.com/juanfont/headscale"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"google.golang.org/grpc/status"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(nodeCmd)
|
||||
listNodesCmd.Flags().StringP("user", "u", "", "Filter by user")
|
||||
listNodesCmd.Flags().StringP("user", "n", "", "Filter by user")
|
||||
listNodesCmd.Flags().BoolP("tags", "t", false, "Show tags")
|
||||
nodeCmd.AddCommand(listNodesCmd)
|
||||
|
||||
listNodeRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
nodeCmd.AddCommand(listNodeRoutesCmd)
|
||||
|
||||
registerNodeCmd.Flags().StringP("user", "u", "", "User")
|
||||
registerNodeCmd.Flags().StringP("user", "n", "", "User")
|
||||
err := registerNodeCmd.MarkFlagRequired("user")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
registerNodeCmd.Flags().StringP("key", "k", "", "Key")
|
||||
mustMarkRequired(registerNodeCmd, "user", "key")
|
||||
err = registerNodeCmd.MarkFlagRequired("key")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(registerNodeCmd)
|
||||
|
||||
expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
expireNodeCmd.Flags().StringP("expiry", "e", "", "Set expire to (RFC3339 format, e.g. 2025-08-27T10:00:00Z), or leave empty to expire immediately.")
|
||||
expireNodeCmd.Flags().BoolP("disable", "d", false, "Disable key expiry (node will never expire)")
|
||||
mustMarkRequired(expireNodeCmd, "identifier")
|
||||
err = expireNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(expireNodeCmd)
|
||||
|
||||
renameNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
mustMarkRequired(renameNodeCmd, "identifier")
|
||||
err = renameNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(renameNodeCmd)
|
||||
|
||||
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
mustMarkRequired(deleteNodeCmd, "identifier")
|
||||
err = deleteNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(deleteNodeCmd)
|
||||
|
||||
moveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
|
||||
err = moveNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
|
||||
moveNodeCmd.Flags().StringP("user", "n", "", "New user")
|
||||
|
||||
err = moveNodeCmd.MarkFlagRequired("user")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(moveNodeCmd)
|
||||
|
||||
tagCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
mustMarkRequired(tagCmd, "identifier")
|
||||
tagCmd.Flags().StringSliceP("tags", "t", []string{}, "List of tags to add to the node")
|
||||
|
||||
err = tagCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
}
|
||||
tagCmd.Flags().
|
||||
StringSliceP("tags", "t", []string{}, "List of tags to add to the node")
|
||||
nodeCmd.AddCommand(tagCmd)
|
||||
|
||||
approveRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
mustMarkRequired(approveRoutesCmd, "identifier")
|
||||
approveRoutesCmd.Flags().StringSliceP("routes", "r", []string{}, `List of routes that will be approved (comma-separated, e.g. "10.0.0.0/8,192.168.0.0/24" or empty string to remove all approved routes)`)
|
||||
nodeCmd.AddCommand(approveRoutesCmd)
|
||||
|
||||
nodeCmd.AddCommand(backfillNodeIPsCmd)
|
||||
}
|
||||
|
||||
var nodeCmd = &cobra.Command{
|
||||
Use: "nodes",
|
||||
Short: "Manage the nodes of Headscale",
|
||||
Aliases: []string{"node"},
|
||||
Aliases: []string{"node", "machine", "machines"},
|
||||
}
|
||||
|
||||
var registerNodeCmd = &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Registers a node to your network",
|
||||
Deprecated: "use 'headscale auth register --auth-id <id> --user <user>' instead",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
user, _ := cmd.Flags().GetString("user")
|
||||
registrationID, _ := cmd.Flags().GetString("key")
|
||||
Use: "register",
|
||||
Short: "Registers a machine to your network",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
|
||||
request := &v1.RegisterNodeRequest{
|
||||
Key: registrationID,
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
machineKey, err := cmd.Flags().GetString("key")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting node key from flag: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
request := &v1.RegisterMachineRequest{
|
||||
Key: machineKey,
|
||||
User: user,
|
||||
}
|
||||
|
||||
response, err := client.RegisterNode(ctx, request)
|
||||
response, err := client.RegisterMachine(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registering node: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot register machine: %s\n",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(
|
||||
cmd,
|
||||
response.GetNode(),
|
||||
fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()))
|
||||
}),
|
||||
SuccessOutput(
|
||||
response.Machine,
|
||||
fmt.Sprintf("Machine %s registered", response.Machine.GivenName), output)
|
||||
},
|
||||
}
|
||||
|
||||
var listNodesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List nodes",
|
||||
Aliases: []string{"ls", "show"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
user, _ := cmd.Flags().GetString("user")
|
||||
|
||||
response, err := client.ListNodes(ctx, &v1.ListNodesRequest{User: user})
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing nodes: %w", err)
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printListOutput(cmd, response.GetNodes(), func() error {
|
||||
tableData, err := nodesToPtables(user, response.GetNodes())
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting to table: %w", err)
|
||||
}
|
||||
|
||||
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
var listNodeRoutesCmd = &cobra.Command{
|
||||
Use: "list-routes",
|
||||
Short: "List routes available on nodes",
|
||||
Aliases: []string{"lsr", "routes"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
identifier, _ := cmd.Flags().GetUint64("identifier")
|
||||
|
||||
response, err := client.ListNodes(ctx, &v1.ListNodesRequest{})
|
||||
showTags, err := cmd.Flags().GetBool("tags")
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing nodes: %w", err)
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting tags flag: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
nodes := response.GetNodes()
|
||||
if identifier != 0 {
|
||||
for _, node := range response.GetNodes() {
|
||||
if node.GetId() == identifier {
|
||||
nodes = []*v1.Node{node}
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
request := &v1.ListMachinesRequest{
|
||||
User: user,
|
||||
}
|
||||
|
||||
nodes = lo.Filter(nodes, func(n *v1.Node, _ int) bool {
|
||||
return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0)
|
||||
})
|
||||
response, err := client.ListMachines(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot get nodes: %s", status.Convert(err).Message()),
|
||||
output,
|
||||
)
|
||||
|
||||
return printListOutput(cmd, nodes, func() error {
|
||||
return pterm.DefaultTable.WithHasHeader().WithData(nodeRoutesToPtables(nodes)).Render()
|
||||
})
|
||||
}),
|
||||
return
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.Machines, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData, err := nodesToPtables(user, showTags, response.Machines)
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var expireNodeCmd = &cobra.Command{
|
||||
Use: "expire",
|
||||
Short: "Expire (log out) a node in your network",
|
||||
Long: `Expiring a node will keep the node in the database and force it to reauthenticate.
|
||||
|
||||
Use --disable to disable key expiry (node will never expire).`,
|
||||
Use: "expire",
|
||||
Short: "Expire (log out) a machine in your network",
|
||||
Long: "Expiring a node will keep the node in the database and force it to reauthenticate.",
|
||||
Aliases: []string{"logout", "exp", "e"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
identifier, _ := cmd.Flags().GetUint64("identifier")
|
||||
disableExpiry, _ := cmd.Flags().GetBool("disable")
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// Handle disable expiry - node will never expire.
|
||||
if disableExpiry {
|
||||
request := &v1.ExpireNodeRequest{
|
||||
NodeId: identifier,
|
||||
DisableExpiry: true,
|
||||
}
|
||||
|
||||
response, err := client.ExpireNode(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("disabling node expiry: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response.GetNode(), "Node expiry disabled")
|
||||
}
|
||||
|
||||
expiry, _ := cmd.Flags().GetString("expiry")
|
||||
|
||||
now := time.Now()
|
||||
|
||||
expiryTime := now
|
||||
|
||||
if expiry != "" {
|
||||
var err error
|
||||
|
||||
expiryTime, err = time.Parse(time.RFC3339, expiry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing expiry time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
request := &v1.ExpireNodeRequest{
|
||||
NodeId: identifier,
|
||||
Expiry: timestamppb.New(expiryTime),
|
||||
}
|
||||
|
||||
response, err := client.ExpireNode(ctx, request)
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
return fmt.Errorf("expiring node: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if now.Equal(expiryTime) || now.After(expiryTime) {
|
||||
return printOutput(cmd, response.GetNode(), "Node expired")
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ExpireMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
return printOutput(cmd, response.GetNode(), "Node expiration updated")
|
||||
}),
|
||||
response, err := client.ExpireMachine(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot expire machine: %s\n",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(response.Machine, "Machine expired", output)
|
||||
},
|
||||
}
|
||||
|
||||
var renameNodeCmd = &cobra.Command{
|
||||
Use: "rename NEW_NAME",
|
||||
Short: "Renames a node in your network",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
identifier, _ := cmd.Flags().GetUint64("identifier")
|
||||
Short: "Renames a machine in your network",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
newName := ""
|
||||
if len(args) > 0 {
|
||||
newName = args[0]
|
||||
}
|
||||
|
||||
request := &v1.RenameNodeRequest{
|
||||
NodeId: identifier,
|
||||
NewName: newName,
|
||||
request := &v1.RenameMachineRequest{
|
||||
MachineId: identifier,
|
||||
NewName: newName,
|
||||
}
|
||||
|
||||
response, err := client.RenameNode(ctx, request)
|
||||
response, err := client.RenameMachine(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming node: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Cannot rename machine: %s\n",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(cmd, response.GetNode(), "Node renamed")
|
||||
}),
|
||||
SuccessOutput(response.Machine, "Machine renamed", output)
|
||||
},
|
||||
}
|
||||
|
||||
var deleteNodeCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete a node",
|
||||
Aliases: []string{"del"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
identifier, _ := cmd.Flags().GetUint64("identifier")
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
getRequest := &v1.GetNodeRequest{
|
||||
NodeId: identifier,
|
||||
}
|
||||
|
||||
getResponse, err := client.GetNode(ctx, getRequest)
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting node: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteRequest := &v1.DeleteNodeRequest{
|
||||
NodeId: identifier,
|
||||
}
|
||||
|
||||
if !confirmAction(cmd, fmt.Sprintf(
|
||||
"Do you want to remove the node %s?",
|
||||
getResponse.GetNode().GetName(),
|
||||
)) {
|
||||
return printOutput(cmd, map[string]string{"Result": "Node not deleted"}, "Node not deleted")
|
||||
}
|
||||
|
||||
_, err = client.DeleteNode(ctx, deleteRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting node: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(
|
||||
cmd,
|
||||
map[string]string{"Result": "Node deleted"},
|
||||
"Node deleted",
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
var backfillNodeIPsCmd = &cobra.Command{
|
||||
Use: "backfillips",
|
||||
Short: "Backfill IPs missing from nodes",
|
||||
Long: `
|
||||
Backfill IPs can be used to add/remove IPs from nodes
|
||||
based on the current configuration of Headscale.
|
||||
|
||||
If there are nodes that does not have IPv4 or IPv6
|
||||
even if prefixes for both are configured in the config,
|
||||
this command can be used to assign IPs of the sort to
|
||||
all nodes that are missing.
|
||||
|
||||
If you remove IPv4 or IPv6 prefixes from the config,
|
||||
it can be run to remove the IPs that should no longer
|
||||
be assigned to nodes.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirmAction(cmd, "Are you sure that you want to assign/remove IPs to/from nodes?") {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
changes, err := client.BackfillNodeIPs(ctx, &v1.BackfillNodeIPsRequest{Confirmed: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfilling IPs: %w", err)
|
||||
getRequest := &v1.GetMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
return printOutput(cmd, changes, "Node IPs backfilled successfully")
|
||||
getResponse, err := client.GetMachine(ctx, getRequest)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Error getting node node: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteRequest := &v1.DeleteMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
confirm := false
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
if !force {
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf(
|
||||
"Do you want to remove the node %s?",
|
||||
getResponse.GetMachine().Name,
|
||||
),
|
||||
}
|
||||
err = survey.AskOne(prompt, &confirm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if confirm || force {
|
||||
response, err := client.DeleteMachine(ctx, deleteRequest)
|
||||
if output != "" {
|
||||
SuccessOutput(response, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Error deleting node: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
SuccessOutput(
|
||||
map[string]string{"Result": "Node deleted"},
|
||||
"Node deleted",
|
||||
output,
|
||||
)
|
||||
} else {
|
||||
SuccessOutput(map[string]string{"Result": "Node not deleted"}, "Node not deleted", output)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var moveNodeCmd = &cobra.Command{
|
||||
Use: "move",
|
||||
Short: "Move node to another user",
|
||||
Aliases: []string{"mv"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting user: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
getRequest := &v1.GetMachineRequest{
|
||||
MachineId: identifier,
|
||||
}
|
||||
|
||||
_, err = client.GetMachine(ctx, getRequest)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Error getting node: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
moveRequest := &v1.MoveMachineRequest{
|
||||
MachineId: identifier,
|
||||
User: user,
|
||||
}
|
||||
|
||||
moveResponse, err := client.MoveMachine(ctx, moveRequest)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"Error moving node: %s",
|
||||
status.Convert(err).Message(),
|
||||
),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
SuccessOutput(moveResponse.Machine, "Node moved to another user", output)
|
||||
},
|
||||
}
|
||||
|
||||
func nodesToPtables(
|
||||
currentUser string,
|
||||
nodes []*v1.Node,
|
||||
showTags bool,
|
||||
machines []*v1.Machine,
|
||||
) (pterm.TableData, error) {
|
||||
tableHeader := []string{
|
||||
"ID",
|
||||
@@ -315,119 +472,129 @@ func nodesToPtables(
|
||||
"MachineKey",
|
||||
"NodeKey",
|
||||
"User",
|
||||
"Tags",
|
||||
"IP addresses",
|
||||
"Ephemeral",
|
||||
"Last seen",
|
||||
"Expiration",
|
||||
"Connected",
|
||||
"Online",
|
||||
"Expired",
|
||||
}
|
||||
if showTags {
|
||||
tableHeader = append(tableHeader, []string{
|
||||
"ForcedTags",
|
||||
"InvalidTags",
|
||||
"ValidTags",
|
||||
}...)
|
||||
}
|
||||
tableData := pterm.TableData{tableHeader}
|
||||
|
||||
for _, node := range nodes {
|
||||
for _, machine := range machines {
|
||||
var ephemeral bool
|
||||
if node.GetPreAuthKey() != nil && node.GetPreAuthKey().GetEphemeral() {
|
||||
if machine.PreAuthKey != nil && machine.PreAuthKey.Ephemeral {
|
||||
ephemeral = true
|
||||
}
|
||||
|
||||
var (
|
||||
lastSeen time.Time
|
||||
lastSeenTime string
|
||||
)
|
||||
|
||||
if node.GetLastSeen() != nil {
|
||||
lastSeen = node.GetLastSeen().AsTime()
|
||||
lastSeenTime = lastSeen.Format(HeadscaleDateTimeFormat)
|
||||
var lastSeen time.Time
|
||||
var lastSeenTime string
|
||||
if machine.LastSeen != nil {
|
||||
lastSeen = machine.LastSeen.AsTime()
|
||||
lastSeenTime = lastSeen.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
var (
|
||||
expiry time.Time
|
||||
expiryTime string
|
||||
)
|
||||
|
||||
if node.GetExpiry() != nil {
|
||||
expiry = node.GetExpiry().AsTime()
|
||||
expiryTime = expiry.Format(HeadscaleDateTimeFormat)
|
||||
var expiry time.Time
|
||||
var expiryTime string
|
||||
if machine.Expiry != nil {
|
||||
expiry = machine.Expiry.AsTime()
|
||||
expiryTime = expiry.Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
expiryTime = "N/A"
|
||||
}
|
||||
|
||||
var machineKey key.MachinePublic
|
||||
|
||||
err := machineKey.UnmarshalText(
|
||||
[]byte(node.GetMachineKey()),
|
||||
[]byte(headscale.MachinePublicKeyEnsurePrefix(machine.MachineKey)),
|
||||
)
|
||||
if err != nil {
|
||||
machineKey = key.MachinePublic{}
|
||||
}
|
||||
|
||||
var nodeKey key.NodePublic
|
||||
|
||||
err = nodeKey.UnmarshalText(
|
||||
[]byte(node.GetNodeKey()),
|
||||
[]byte(headscale.NodePublicKeyEnsurePrefix(machine.NodeKey)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var online string
|
||||
if node.GetOnline() {
|
||||
if machine.Online {
|
||||
online = pterm.LightGreen("online")
|
||||
} else {
|
||||
online = pterm.LightRed("offline")
|
||||
}
|
||||
|
||||
var expired string
|
||||
if node.GetExpiry() != nil && node.GetExpiry().AsTime().Before(time.Now()) {
|
||||
expired = pterm.LightRed("yes")
|
||||
} else {
|
||||
if expiry.IsZero() || expiry.After(time.Now()) {
|
||||
expired = pterm.LightGreen("no")
|
||||
} else {
|
||||
expired = pterm.LightRed("yes")
|
||||
}
|
||||
|
||||
var tagsBuilder strings.Builder
|
||||
|
||||
for _, tag := range node.GetTags() {
|
||||
tagsBuilder.WriteString("\n" + tag)
|
||||
var forcedTags string
|
||||
for _, tag := range machine.ForcedTags {
|
||||
forcedTags += "," + tag
|
||||
}
|
||||
|
||||
tags := strings.TrimLeft(tagsBuilder.String(), "\n")
|
||||
forcedTags = strings.TrimLeft(forcedTags, ",")
|
||||
var invalidTags string
|
||||
for _, tag := range machine.InvalidTags {
|
||||
if !contains(machine.ForcedTags, tag) {
|
||||
invalidTags += "," + pterm.LightRed(tag)
|
||||
}
|
||||
}
|
||||
invalidTags = strings.TrimLeft(invalidTags, ",")
|
||||
var validTags string
|
||||
for _, tag := range machine.ValidTags {
|
||||
if !contains(machine.ForcedTags, tag) {
|
||||
validTags += "," + pterm.LightGreen(tag)
|
||||
}
|
||||
}
|
||||
validTags = strings.TrimLeft(validTags, ",")
|
||||
|
||||
var user string
|
||||
if node.GetUser() != nil {
|
||||
user = node.GetUser().GetName()
|
||||
if currentUser == "" || (currentUser == machine.User.Name) {
|
||||
user = pterm.LightMagenta(machine.User.Name)
|
||||
} else {
|
||||
// Shared into this user
|
||||
user = pterm.LightYellow(machine.User.Name)
|
||||
}
|
||||
|
||||
var ipBuilder strings.Builder
|
||||
|
||||
for _, addr := range node.GetIpAddresses() {
|
||||
ip, err := netip.ParseAddr(addr)
|
||||
if err == nil {
|
||||
if ipBuilder.Len() > 0 {
|
||||
ipBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
ipBuilder.WriteString(ip.String())
|
||||
var IPV4Address string
|
||||
var IPV6Address string
|
||||
for _, addr := range machine.IpAddresses {
|
||||
if netip.MustParseAddr(addr).Is4() {
|
||||
IPV4Address = addr
|
||||
} else {
|
||||
IPV6Address = addr
|
||||
}
|
||||
}
|
||||
|
||||
ipAddresses := ipBuilder.String()
|
||||
|
||||
nodeData := []string{
|
||||
strconv.FormatUint(node.GetId(), util.Base10),
|
||||
node.GetName(),
|
||||
node.GetGivenName(),
|
||||
strconv.FormatUint(machine.Id, headscale.Base10),
|
||||
machine.Name,
|
||||
machine.GetGivenName(),
|
||||
machineKey.ShortString(),
|
||||
nodeKey.ShortString(),
|
||||
user,
|
||||
tags,
|
||||
ipAddresses,
|
||||
strings.Join([]string{IPV4Address, IPV6Address}, ", "),
|
||||
strconv.FormatBool(ephemeral),
|
||||
lastSeenTime,
|
||||
expiryTime,
|
||||
online,
|
||||
expired,
|
||||
}
|
||||
if showTags {
|
||||
nodeData = append(nodeData, []string{forcedTags, invalidTags, validTags}...)
|
||||
}
|
||||
tableData = append(
|
||||
tableData,
|
||||
nodeData,
|
||||
@@ -437,76 +604,60 @@ func nodesToPtables(
|
||||
return tableData, nil
|
||||
}
|
||||
|
||||
func nodeRoutesToPtables(
|
||||
nodes []*v1.Node,
|
||||
) pterm.TableData {
|
||||
tableHeader := []string{
|
||||
"ID",
|
||||
"Hostname",
|
||||
"Approved",
|
||||
"Available",
|
||||
"Serving (Primary)",
|
||||
}
|
||||
tableData := pterm.TableData{tableHeader}
|
||||
|
||||
for _, node := range nodes {
|
||||
nodeData := []string{
|
||||
strconv.FormatUint(node.GetId(), util.Base10),
|
||||
node.GetGivenName(),
|
||||
strings.Join(node.GetApprovedRoutes(), "\n"),
|
||||
strings.Join(node.GetAvailableRoutes(), "\n"),
|
||||
strings.Join(node.GetSubnetRoutes(), "\n"),
|
||||
}
|
||||
tableData = append(
|
||||
tableData,
|
||||
nodeData,
|
||||
)
|
||||
}
|
||||
|
||||
return tableData
|
||||
}
|
||||
|
||||
var tagCmd = &cobra.Command{
|
||||
Use: "tag",
|
||||
Short: "Manage the tags of a node",
|
||||
Aliases: []string{"tags", "t"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
identifier, _ := cmd.Flags().GetUint64("identifier")
|
||||
tagsToSet, _ := cmd.Flags().GetStringSlice("tags")
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
// Sending tags to node
|
||||
request := &v1.SetTagsRequest{
|
||||
NodeId: identifier,
|
||||
Tags: tagsToSet,
|
||||
// retrieve flags from CLI
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
tagsToSet, err := cmd.Flags().GetStringSlice("tags")
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error retrieving list of tags to add to machine, %v", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Sending tags to machine
|
||||
request := &v1.SetTagsRequest{
|
||||
MachineId: identifier,
|
||||
Tags: tagsToSet,
|
||||
}
|
||||
resp, err := client.SetTags(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting tags: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error while sending tags to headscale: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.GetNode(), "Node updated")
|
||||
}),
|
||||
}
|
||||
|
||||
var approveRoutesCmd = &cobra.Command{
|
||||
Use: "approve-routes",
|
||||
Short: "Manage the approved routes of a node",
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
identifier, _ := cmd.Flags().GetUint64("identifier")
|
||||
routes, _ := cmd.Flags().GetStringSlice("routes")
|
||||
|
||||
// Sending routes to node
|
||||
request := &v1.SetApprovedRoutesRequest{
|
||||
NodeId: identifier,
|
||||
Routes: routes,
|
||||
}
|
||||
|
||||
resp, err := client.SetApprovedRoutes(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting approved routes: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, resp.GetNode(), "Node updated")
|
||||
}),
|
||||
if resp != nil {
|
||||
SuccessOutput(
|
||||
resp.GetMachine(),
|
||||
"Machine updated",
|
||||
output,
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/policy"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/spf13/cobra"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
const (
|
||||
bypassFlag = "bypass-grpc-and-access-database-directly" //nolint:gosec // not a credential
|
||||
)
|
||||
|
||||
var errAborted = errors.New("command aborted by user")
|
||||
|
||||
// bypassDatabase loads the server config and opens the database directly,
|
||||
// bypassing the gRPC server. The caller is responsible for closing the
|
||||
// returned database handle.
|
||||
func bypassDatabase() (*db.HSDatabase, error) {
|
||||
cfg, err := types.LoadServerConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading config: %w", err)
|
||||
}
|
||||
|
||||
d, err := db.NewHeadscaleDatabase(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(policyCmd)
|
||||
|
||||
getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running")
|
||||
policyCmd.AddCommand(getPolicy)
|
||||
|
||||
setPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
|
||||
setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running")
|
||||
mustMarkRequired(setPolicy, "file")
|
||||
policyCmd.AddCommand(setPolicy)
|
||||
|
||||
checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format")
|
||||
mustMarkRequired(checkPolicy, "file")
|
||||
policyCmd.AddCommand(checkPolicy)
|
||||
}
|
||||
|
||||
var policyCmd = &cobra.Command{
|
||||
Use: "policy",
|
||||
Short: "Manage the Headscale ACL Policy",
|
||||
}
|
||||
|
||||
var getPolicy = &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Print the current ACL Policy",
|
||||
Aliases: []string{"show", "view", "fetch"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var policyData string
|
||||
|
||||
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
|
||||
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
|
||||
return errAborted
|
||||
}
|
||||
|
||||
d, err := bypassDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
pol, err := d.GetPolicy()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading policy from database: %w", err)
|
||||
}
|
||||
|
||||
policyData = pol.Data
|
||||
} else {
|
||||
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading ACL policy: %w", err)
|
||||
}
|
||||
|
||||
policyData = response.GetPolicy()
|
||||
}
|
||||
|
||||
// This does not pass output format as we don't support yaml, json or
|
||||
// json-line output for this command. It is HuJSON already.
|
||||
fmt.Println(policyData)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var setPolicy = &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Updates the ACL Policy",
|
||||
Long: `
|
||||
Updates the existing ACL Policy with the provided policy. The policy must be a valid HuJSON object.
|
||||
This command only works when the acl.policy_mode is set to "db", and the policy will be stored in the database.`,
|
||||
Aliases: []string{"put", "update"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policyPath, _ := cmd.Flags().GetString("file")
|
||||
|
||||
policyBytes, err := os.ReadFile(policyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading policy file: %w", err)
|
||||
}
|
||||
|
||||
if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass {
|
||||
if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") {
|
||||
return errAborted
|
||||
}
|
||||
|
||||
d, err := bypassDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
users, err := d.ListUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading users for policy validation: %w", err)
|
||||
}
|
||||
|
||||
_, err = policy.NewPolicyManager(policyBytes, users, views.Slice[types.NodeView]{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
}
|
||||
|
||||
_, err = d.SetPolicy(string(policyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting ACL policy: %w", err)
|
||||
}
|
||||
} else {
|
||||
request := &v1.SetPolicyRequest{Policy: string(policyBytes)}
|
||||
|
||||
ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to headscale: %w", err)
|
||||
}
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
_, err = client.SetPolicy(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting ACL policy: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Policy updated.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var checkPolicy = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check the Policy file for errors",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policyPath, _ := cmd.Flags().GetString("file")
|
||||
|
||||
policyBytes, err := os.ReadFile(policyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading policy file: %w", err)
|
||||
}
|
||||
|
||||
_, err = policy.NewPolicyManager(policyBytes, nil, views.Slice[types.NodeView]{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing policy file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Policy is valid")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -18,10 +20,14 @@ const (
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(preauthkeysCmd)
|
||||
preauthkeysCmd.PersistentFlags().StringP("user", "n", "", "User")
|
||||
err := preauthkeysCmd.MarkPersistentFlagRequired("user")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
preauthkeysCmd.AddCommand(listPreAuthKeys)
|
||||
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
|
||||
preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
|
||||
preauthkeysCmd.AddCommand(deletePreAuthKeyCmd)
|
||||
createPreAuthKeyCmd.PersistentFlags().
|
||||
Bool("reusable", false, "Make the preauthkey reusable")
|
||||
createPreAuthKeyCmd.PersistentFlags().
|
||||
@@ -30,9 +36,6 @@ func init() {
|
||||
StringP("expiration", "e", DefaultPreAuthKeyExpiry, "Human-readable expiration of the key (e.g. 30m, 24h)")
|
||||
createPreAuthKeyCmd.Flags().
|
||||
StringSlice("tags", []string{}, "Tags to automatically assign to node")
|
||||
createPreAuthKeyCmd.PersistentFlags().Uint64P("user", "u", 0, "User identifier (ID)")
|
||||
expirePreAuthKeyCmd.PersistentFlags().Uint64P("id", "i", 0, "Authkey ID")
|
||||
deletePreAuthKeyCmd.PersistentFlags().Uint64P("id", "i", 0, "Authkey ID")
|
||||
}
|
||||
|
||||
var preauthkeysCmd = &cobra.Command{
|
||||
@@ -43,136 +46,212 @@ var preauthkeysCmd = &cobra.Command{
|
||||
|
||||
var listPreAuthKeys = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all preauthkeys",
|
||||
Short: "List the preauthkeys for this user",
|
||||
Aliases: []string{"ls", "show"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{})
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing preauthkeys: %w", err)
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printListOutput(cmd, response.GetPreAuthKeys(), func() error {
|
||||
tableData := pterm.TableData{
|
||||
{
|
||||
"ID",
|
||||
"Key/Prefix",
|
||||
"Reusable",
|
||||
"Ephemeral",
|
||||
"Used",
|
||||
"Expiration",
|
||||
"Created",
|
||||
"Owner",
|
||||
},
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ListPreAuthKeysRequest{
|
||||
User: user,
|
||||
}
|
||||
|
||||
response, err := client.ListPreAuthKeys(ctx, request)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error getting the list of keys: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
SuccessOutput(response.PreAuthKeys, "", output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData := pterm.TableData{
|
||||
{
|
||||
"ID",
|
||||
"Key",
|
||||
"Reusable",
|
||||
"Ephemeral",
|
||||
"Used",
|
||||
"Expiration",
|
||||
"Created",
|
||||
"Tags",
|
||||
},
|
||||
}
|
||||
for _, key := range response.PreAuthKeys {
|
||||
expiration := "-"
|
||||
if key.GetExpiration() != nil {
|
||||
expiration = ColourTime(key.Expiration.AsTime())
|
||||
}
|
||||
|
||||
for _, key := range response.GetPreAuthKeys() {
|
||||
expiration := "-"
|
||||
if key.GetExpiration() != nil {
|
||||
expiration = ColourTime(key.GetExpiration().AsTime())
|
||||
}
|
||||
|
||||
var owner string
|
||||
if len(key.GetAclTags()) > 0 {
|
||||
owner = strings.Join(key.GetAclTags(), "\n")
|
||||
} else if key.GetUser() != nil {
|
||||
owner = key.GetUser().GetName()
|
||||
} else {
|
||||
owner = "-"
|
||||
}
|
||||
|
||||
tableData = append(tableData, []string{
|
||||
strconv.FormatUint(key.GetId(), util.Base10),
|
||||
key.GetKey(),
|
||||
strconv.FormatBool(key.GetReusable()),
|
||||
strconv.FormatBool(key.GetEphemeral()),
|
||||
strconv.FormatBool(key.GetUsed()),
|
||||
expiration,
|
||||
key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat),
|
||||
owner,
|
||||
})
|
||||
var reusable string
|
||||
if key.GetEphemeral() {
|
||||
reusable = "N/A"
|
||||
} else {
|
||||
reusable = fmt.Sprintf("%v", key.GetReusable())
|
||||
}
|
||||
|
||||
return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
})
|
||||
}),
|
||||
aclTags := ""
|
||||
|
||||
for _, tag := range key.AclTags {
|
||||
aclTags += "," + tag
|
||||
}
|
||||
|
||||
aclTags = strings.TrimLeft(aclTags, ",")
|
||||
|
||||
tableData = append(tableData, []string{
|
||||
key.GetId(),
|
||||
key.GetKey(),
|
||||
reusable,
|
||||
strconv.FormatBool(key.GetEphemeral()),
|
||||
strconv.FormatBool(key.GetUsed()),
|
||||
expiration,
|
||||
key.GetCreatedAt().AsTime().Format("2006-01-02 15:04:05"),
|
||||
aclTags,
|
||||
})
|
||||
|
||||
}
|
||||
err = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render()
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Failed to render pterm table: %s", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var createPreAuthKeyCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Creates a new preauthkey",
|
||||
Short: "Creates a new preauthkey in the specified user",
|
||||
Aliases: []string{"c", "new"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
user, _ := cmd.Flags().GetUint64("user")
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reusable, _ := cmd.Flags().GetBool("reusable")
|
||||
ephemeral, _ := cmd.Flags().GetBool("ephemeral")
|
||||
tags, _ := cmd.Flags().GetStringSlice("tags")
|
||||
|
||||
expiration, err := expirationFromFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace().
|
||||
Bool("reusable", reusable).
|
||||
Bool("ephemeral", ephemeral).
|
||||
Str("user", user).
|
||||
Msg("Preparing to create preauthkey")
|
||||
|
||||
request := &v1.CreatePreAuthKeyRequest{
|
||||
User: user,
|
||||
Reusable: reusable,
|
||||
Ephemeral: ephemeral,
|
||||
AclTags: tags,
|
||||
Expiration: expiration,
|
||||
User: user,
|
||||
Reusable: reusable,
|
||||
Ephemeral: ephemeral,
|
||||
AclTags: tags,
|
||||
}
|
||||
|
||||
durationStr, _ := cmd.Flags().GetString("expiration")
|
||||
|
||||
duration, err := model.ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Could not parse duration: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
expiration := time.Now().UTC().Add(time.Duration(duration))
|
||||
|
||||
log.Trace().
|
||||
Dur("expiration", time.Duration(duration)).
|
||||
Msg("expiration has been set")
|
||||
|
||||
request.Expiration = timestamppb.New(expiration)
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
response, err := client.CreatePreAuthKey(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating preauthkey: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot create Pre Auth Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(cmd, response.GetPreAuthKey(), response.GetPreAuthKey().GetKey())
|
||||
}),
|
||||
SuccessOutput(response.PreAuthKey, response.PreAuthKey.Key, output)
|
||||
},
|
||||
}
|
||||
|
||||
var expirePreAuthKeyCmd = &cobra.Command{
|
||||
Use: "expire",
|
||||
Use: "expire KEY",
|
||||
Short: "Expire a preauthkey",
|
||||
Aliases: []string{"revoke", "exp", "e"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
id, _ := cmd.Flags().GetUint64("id")
|
||||
|
||||
if id == 0 {
|
||||
return fmt.Errorf("missing --id parameter: %w", errMissingParameter)
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errMissingParameter
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx, client, conn, cancel := getHeadscaleCLIClient()
|
||||
defer cancel()
|
||||
defer conn.Close()
|
||||
|
||||
request := &v1.ExpirePreAuthKeyRequest{
|
||||
Id: id,
|
||||
User: user,
|
||||
Key: args[0],
|
||||
}
|
||||
|
||||
response, err := client.ExpirePreAuthKey(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expiring preauthkey: %w", err)
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Cannot expire Pre Auth Key: %s\n", err),
|
||||
output,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "Key expired")
|
||||
}),
|
||||
}
|
||||
|
||||
var deletePreAuthKeyCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete a preauthkey",
|
||||
Aliases: []string{"del", "rm", "d"},
|
||||
RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error {
|
||||
id, _ := cmd.Flags().GetUint64("id")
|
||||
|
||||
if id == 0 {
|
||||
return fmt.Errorf("missing --id parameter: %w", errMissingParameter)
|
||||
}
|
||||
|
||||
request := &v1.DeletePreAuthKeyRequest{
|
||||
Id: id,
|
||||
}
|
||||
|
||||
response, err := client.DeletePreAuthKey(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting preauthkey: %w", err)
|
||||
}
|
||||
|
||||
return printOutput(cmd, response, "Key deleted")
|
||||
}),
|
||||
SuccessOutput(response, "Key expired", output)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func ColourTime(date time.Time) string {
|
||||
dateStr := date.Format(HeadscaleDateTimeFormat)
|
||||
dateStr := date.Format("2006-01-02 15:04:05")
|
||||
|
||||
if date.After(time.Now()) {
|
||||
dateStr = pterm.LightGreen(dateStr)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user