Compare commits
1 Commits
will/vizer
...
upnpdebug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0b588618 |
@@ -1 +0,0 @@
|
||||
suppress_failure_on_regression: true
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,2 +1 @@
|
||||
go.mod filter=go-mod
|
||||
*.go diff=golang
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: 'needs-triage'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: Bug report
|
||||
description: File a bug report. If you need help, contact support instead
|
||||
labels: [needs-triage, bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Need help with your tailnet? [Contact support](https://tailscale.com/contact/support) instead.
|
||||
Otherwise, please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues) before filing a new one.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What is the issue?
|
||||
description: What happened? What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: What are the steps you took that hit this issue?
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: changes
|
||||
attributes:
|
||||
label: Are there any recent changes that introduced the issue?
|
||||
description: If so, what are those changes?
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: OS
|
||||
description: What OS are you using? You may select more than one.
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
- iOS
|
||||
- Android
|
||||
- Synology
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: What OS version are you using?
|
||||
placeholder: e.g., Debian 11.0, macOS Big Sur 11.6, Synology DSM 7
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: ts-version
|
||||
attributes:
|
||||
label: Tailscale version
|
||||
description: What Tailscale version are you using?
|
||||
placeholder: e.g., 1.14.4
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-software
|
||||
attributes:
|
||||
label: Other software
|
||||
description: What [other software](https://github.com/tailscale/tailscale/wiki/OtherSoftwareInterop) (networking, security, etc) are you running?
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: bug-report
|
||||
attributes:
|
||||
label: Bug report
|
||||
description: Please run [`tailscale bugreport`](https://tailscale.com/kb/1080/cli/?q=Cli#bugreport) and share the bug identifier. The identifier is a random string which allows Tailscale support to locate your account and gives a point to focus on when looking for errors.
|
||||
placeholder: e.g., BUG-1b7641a16971a9cd75822c0ed8043fee70ae88cf05c52981dc220eb96a5c49a8-20210427151443Z-fbcd4fd3a4b7ad94
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug report!
|
||||
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Support
|
||||
url: https://tailscale.com/contact/support/
|
||||
about: Contact us for support
|
||||
- name: Troubleshooting
|
||||
- name: Support and Product Questions
|
||||
url: https://tailscale.com/kb/1023/troubleshooting
|
||||
about: See the troubleshooting guide for help addressing common issues
|
||||
about: Please send support questions and questions about the Tailscale product to support@tailscale.com
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'needs-triage'
|
||||
assignees: ''
|
||||
---
|
||||
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Feature request
|
||||
description: Propose a new feature
|
||||
title: "FR: "
|
||||
labels: [needs-triage, fr]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please check if your feature request is [already filed](https://github.com/tailscale/tailscale/issues).
|
||||
Tell us about your idea!
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What are you trying to do?
|
||||
description: Tell us about the problem you're trying to solve.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: How should we solve this?
|
||||
description: If you have an idea of how you'd like to see this feature work, let us know.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: What is the impact of not solving this?
|
||||
description: (How) Are you currently working around the issue?
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: Any additional context to share, e.g., links
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a feature request!
|
||||
21
.github/dependabot.yml
vendored
21
.github/dependabot.yml
vendored
@@ -1,21 +0,0 @@
|
||||
# Documentation for this file can be found at:
|
||||
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates
|
||||
version: 2
|
||||
updates:
|
||||
## Disabled between releases. We reenable it briefly after every
|
||||
## stable release, pull in all changes, and close it again so that
|
||||
## the tree remains more stable during development and the upstream
|
||||
## changes have time to soak before the next release.
|
||||
# - package-ecosystem: "gomod"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
# commit-message:
|
||||
# prefix: "go.mod:"
|
||||
# open-pull-requests-limit: 100
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: ".github:"
|
||||
17
.github/licenses.tmpl
vendored
17
.github/licenses.tmpl
vendored
@@ -1,17 +0,0 @@
|
||||
# Tailscale CLI and daemon dependencies
|
||||
|
||||
The following open source dependencies are used to build the [tailscale][] and
|
||||
[tailscaled][] commands. These are primarily used on Linux and BSD variants as
|
||||
well as an [option for macOS][].
|
||||
|
||||
[tailscale]: https://pkg.go.dev/tailscale.com/cmd/tailscale
|
||||
[tailscaled]: https://pkg.go.dev/tailscale.com/cmd/tailscaled
|
||||
[option for macOS]: https://tailscale.com/kb/1065/macos-variants/
|
||||
|
||||
## Go Packages
|
||||
|
||||
Some packages may only be included on certain architectures or operating systems.
|
||||
|
||||
{{ range . }}
|
||||
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
|
||||
{{- end }}
|
||||
75
.github/workflows/codeql-analysis.yml
vendored
75
.github/workflows/codeql-analysis.yml
vendored
@@ -1,75 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, release-branch/* ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '31 14 * * 5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
53
.github/workflows/cross-darwin.yml
vendored
Normal file
53
.github/workflows/cross-darwin.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Darwin-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: macOS build cmd
|
||||
env:
|
||||
GOOS: darwin
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: macOS build tests
|
||||
env:
|
||||
GOOS: darwin
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
53
.github/workflows/cross-freebsd.yml
vendored
Normal file
53
.github/workflows/cross-freebsd.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: FreeBSD-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: FreeBSD build cmd
|
||||
env:
|
||||
GOOS: freebsd
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: FreeBSD build tests
|
||||
env:
|
||||
GOOS: freebsd
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
53
.github/workflows/cross-openbsd.yml
vendored
Normal file
53
.github/workflows/cross-openbsd.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: OpenBSD-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: OpenBSD build cmd
|
||||
env:
|
||||
GOOS: openbsd
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: OpenBSD build tests
|
||||
env:
|
||||
GOOS: openbsd
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
53
.github/workflows/cross-windows.yml
vendored
Normal file
53
.github/workflows/cross-windows.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Windows-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Windows build cmd
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Windows build tests
|
||||
env:
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
28
.github/workflows/depaware.yml
vendored
Normal file
28
.github/workflows/depaware.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: depaware
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: depaware tailscaled
|
||||
run: go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscaled
|
||||
|
||||
- name: depaware tailscale
|
||||
run: go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscale
|
||||
64
.github/workflows/go-licenses.yml
vendored
64
.github/workflows/go-licenses.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: go-licenses
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod or
|
||||
# our license template file. Also allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/licenses.tmpl
|
||||
- .github/workflows/go-licenses.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tailscale:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install go-licenses
|
||||
run: |
|
||||
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
|
||||
|
||||
- name: Run go-licenses
|
||||
env:
|
||||
# include all build tags to include platform-specific dependencies
|
||||
GOFLAGS: "-tags=android,cgo,darwin,freebsd,ios,js,linux,openbsd,wasm,windows"
|
||||
run: |
|
||||
[ -d licenses ] || mkdir licenses
|
||||
go-licenses report tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled > licenses/tailscale.md --template .github/licenses.tmpl
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@ad43dccb4d726ca8514126628bec209b8354b6dd #v4.1.4
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: License Updater <noreply@tailscale.com>
|
||||
committer: License Updater <noreply@tailscale.com>
|
||||
branch: licenses/cli
|
||||
commit-message: "licenses: update tailscale{,d} licenses"
|
||||
title: "licenses: update tailscale{,d} licenses"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
team-reviewers: opensource-license-reviewers
|
||||
34
.github/workflows/go_generate.yml
vendored
Normal file
34
.github/workflows/go_generate.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: go generate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: check 'go generate' is clean
|
||||
run: |
|
||||
mkdir gentools
|
||||
go build -o gentools/stringer golang.org/x/tools/cmd/stringer
|
||||
PATH="$PATH:$(pwd)/gentools" go generate ./...
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
40
.github/workflows/license.yml
vendored
Normal file
40
.github/workflows/license.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: license
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Run license checker
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
48
.github/workflows/linux-race.yml
vendored
Normal file
48
.github/workflows/linux-race.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Linux race
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Basic build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Run tests and benchmarks with -race flag on linux
|
||||
run: go test -race -bench=. -benchtime=1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
48
.github/workflows/linux.yml
vendored
Normal file
48
.github/workflows/linux.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Basic build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Run tests on linux
|
||||
run: go test -bench=. -benchtime=1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
48
.github/workflows/linux32.yml
vendored
Normal file
48
.github/workflows/linux32.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Linux 32-bit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Basic build
|
||||
run: GOARCH=386 go build ./cmd/...
|
||||
|
||||
- name: Run tests on linux
|
||||
run: GOARCH=386 go test -bench=. -benchtime=1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
58
.github/workflows/staticcheck.yml
vendored
Normal file
58
.github/workflows/staticcheck.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: staticcheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.16
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Install staticcheck
|
||||
run: "GOBIN=~/.local/bin go install honnef.co/go/tools/cmd/staticcheck"
|
||||
|
||||
- name: Print staticcheck version
|
||||
run: "staticcheck -version"
|
||||
|
||||
- name: Run staticcheck (linux/amd64)
|
||||
run: "GOOS=linux GOARCH=amd64 staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- name: Run staticcheck (darwin/amd64)
|
||||
run: "GOOS=darwin GOARCH=amd64 staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- name: Run staticcheck (windows/amd64)
|
||||
run: "GOOS=windows GOARCH=amd64 staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- name: Run staticcheck (windows/386)
|
||||
run: "GOOS=windows GOARCH=386 staticcheck -- $(go list ./... | grep -v tempfork)"
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
388
.github/workflows/test.yml
vendored
388
.github/workflows/test.yml
vendored
@@ -1,388 +0,0 @@
|
||||
# This is our main "CI tests" workflow. It runs everything that should run on
|
||||
# both PRs and merged commits, and for the latter reports failures to slack.
|
||||
name: CI
|
||||
|
||||
env:
|
||||
# Our fuzz job, powered by OSS-Fuzz, fails periodically because we upgrade to
|
||||
# new Go versions very eagerly. OSS-Fuzz is a little more conservative, and
|
||||
# ends up being unable to compile our code.
|
||||
#
|
||||
# When this happens, we want to disable the fuzz target until OSS-Fuzz catches
|
||||
# up. However, we also don't want to forget to turn it back on when OSS-Fuzz
|
||||
# can once again build our code.
|
||||
#
|
||||
# This variable toggles the fuzz job between two modes:
|
||||
# - false: we expect fuzzing to be happy, and should report failure if it's not.
|
||||
# - true: we expect fuzzing is broken, and should report failure if it start working.
|
||||
TS_FUZZ_CURRENTLY_BROKEN: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
# For PRs, later CI runs preempt previous ones. e.g. a force push on a PR
|
||||
# cancels running CI jobs and starts all new ones.
|
||||
#
|
||||
# For non-PR pushes, concurrency.group needs to be unique for every distinct
|
||||
# CI run we want to have happen. Use run_id, which in practice means all
|
||||
# non-PR CI runs will be allowed to run without preempting each other.
|
||||
group: ${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- goarch: amd64
|
||||
- goarch: amd64
|
||||
variant: race
|
||||
- goarch: "386" # thanks yaml
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: build all
|
||||
run: ./tool/go build ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: build variant CLIs
|
||||
run: |
|
||||
./build_dist.sh --extra-small ./cmd/tailscaled
|
||||
./build_dist.sh --box ./cmd/tailscaled
|
||||
./build_dist.sh --extra-small --box ./cmd/tailscaled
|
||||
rm -f tailscaled
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: get qemu # for tstest/archtest
|
||||
if: matrix.goarch == 'amd64' && matrix.variant == ''
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y install qemu-user
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: test all
|
||||
if: matrix.variant != 'race'
|
||||
run: ./tool/go test -exec=/tmp/testwrapper -bench=. -benchtime=1x ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: test all (race)
|
||||
if: matrix.variant == 'race'
|
||||
run: ./tool/go test -race -exec=/tmp/testwrapper -bench=. -benchtime=1x ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: check that no tracked files changed
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
- name: check that no new files were added
|
||||
run: |
|
||||
# Note: The "error: pathspec..." you see below is normal!
|
||||
# In the success case in which there are no new untracked files,
|
||||
# git ls-files complains about the pathspec not matching anything.
|
||||
# That's OK. It's not worth the effort to suppress. Please ignore it.
|
||||
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
|
||||
then
|
||||
echo "Build/test created untracked files in the repo (file names above)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
# TODO(raggi): add a go version here.
|
||||
key: ${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
- name: test
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: ./tool/go test -bench . -benchtime 1x ./...
|
||||
|
||||
vm:
|
||||
runs-on: ["self-hosted", "linux", "vm"]
|
||||
# VM tests run with some privileges, don't let them run on 3p PRs.
|
||||
if: github.repository == 'tailscale/tailscale'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
cross: # cross-compile checks, build only.
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
# Note: linux/amd64 is not in this matrix, because that goos/goarch is
|
||||
# tested more exhaustively in the 'test' job above.
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: "386" # thanks yaml
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
# macOS
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
# BSDs
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: build all
|
||||
run: ./tool/go build ./cmd/...
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: "0"
|
||||
- name: build tests
|
||||
run: ./tool/go test -exec=true ./...
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: "0"
|
||||
|
||||
ios: # similar to cross above, but iOS can't build most of the repo. So, just
|
||||
#make it build a few smoke packages.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
|
||||
android:
|
||||
# similar to cross above, but android fails to build a few pieces of the
|
||||
# repo. We should fix those pieces, they're small, but as a stepping stone,
|
||||
# only test the subset of android that our past smoke test checked.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
|
||||
- name: build some
|
||||
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: arm64
|
||||
|
||||
wasm: # builds tsconnect, which is the only wasm build we support
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: build tsconnect client
|
||||
run: ./tool/go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
|
||||
env:
|
||||
GOOS: js
|
||||
GOARCH: wasm
|
||||
- name: build tsconnect server
|
||||
# Note, no GOOS/GOARCH in env on this build step, we're running a build
|
||||
# tool that handles the build itself.
|
||||
run: |
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
|
||||
|
||||
fuzz:
|
||||
# This target periodically breaks (see TS_FUZZ_CURRENTLY_BROKEN at the top
|
||||
# of the file), so it's more complex than usual: the 'build fuzzers' step
|
||||
# might fail, and depending on the value of 'TS_FUZZ_CURRENTLY_BROKEN', that
|
||||
# might or might not be fine. The steps after the build figure out whether
|
||||
# the success/failure is expected, and appropriately pass/fail the job
|
||||
# overall accordingly.
|
||||
#
|
||||
# Practically, this means that all steps after 'build fuzzers' must have an
|
||||
# explicit 'if' condition, because the default condition for steps is
|
||||
# 'success()', meaning "only run this if no previous steps failed".
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: build fuzzers
|
||||
id: build
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
|
||||
# continue-on-error makes steps.build.conclusion be 'success' even if
|
||||
# steps.build.outcome is 'failure'. This means this step does not
|
||||
# contribute to the job's overall pass/fail evaluation.
|
||||
continue-on-error: true
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: report unexpectedly broken fuzz build
|
||||
if: steps.build.outcome == 'failure' && env.TS_FUZZ_CURRENTLY_BROKEN != 'true'
|
||||
run: |
|
||||
echo "fuzzer build failed, see above for why"
|
||||
echo "if the failure is due to OSS-Fuzz not being on the latest Go yet,"
|
||||
echo "set TS_FUZZ_CURRENTLY_BROKEN=true in .github/workflows/test.yml"
|
||||
echo "to temporarily disable fuzzing until OSS-Fuzz works again."
|
||||
exit 1
|
||||
- name: report unexpectedly working fuzz build
|
||||
if: steps.build.outcome == 'success' && env.TS_FUZZ_CURRENTLY_BROKEN == 'true'
|
||||
run: |
|
||||
echo "fuzzer build succeeded, but we expect it to be broken"
|
||||
echo "please set TS_FUZZ_CURRENTLY_BROKEN=false in .github/workflows/test.yml"
|
||||
echo "to reenable fuzz testing"
|
||||
exit 1
|
||||
- name: run fuzzers
|
||||
id: run
|
||||
# Run the fuzzers whenever they're able to build, even if we're going to
|
||||
# report a failure because TS_FUZZ_CURRENTLY_BROKEN is set to the wrong
|
||||
# value.
|
||||
if: steps.build.outcome == 'success'
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
fuzz-seconds: 300
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@v3
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: check depaware
|
||||
run: |
|
||||
export PATH=$(./tool/go env GOROOT)/bin:$PATH
|
||||
find . -name 'depaware.txt' | xargs -n1 dirname | xargs ./tool/go run github.com/tailscale/depaware --check
|
||||
|
||||
go_generate:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1)
|
||||
|
||||
licenses:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: check licenses
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
staticcheck:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
goos: ["linux", "windows", "darwin"]
|
||||
goarch: ["amd64"]
|
||||
include:
|
||||
- goos: "windows"
|
||||
goarch: "386"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: install staticcheck
|
||||
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
|
||||
- name: run staticcheck
|
||||
run: |
|
||||
export GOROOT=$(./tool/go env GOROOT)
|
||||
export PATH=$GOROOT/bin:$PATH
|
||||
staticcheck -- $(./tool/go list ./... | grep -v tempfork)
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
|
||||
notify_slack:
|
||||
# Only notify slack for merged commits, not PR failures.
|
||||
if: failure() && github.event_name == 'push'
|
||||
# Any of these jobs failing causes a slack notification.
|
||||
needs:
|
||||
- android
|
||||
- test
|
||||
- windows
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
- fuzz
|
||||
- depaware
|
||||
- go_generate
|
||||
- go_mod_tidy
|
||||
- licenses
|
||||
- staticcheck
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: notify
|
||||
uses: ruby/action-slack@v3.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"title": "Failure: ${{ github.workflow }}",
|
||||
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
|
||||
"text": "${{ github.repository }}@${{ github.ref_name }}: <https://github.com/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>",
|
||||
"fields": [{ "value": ${{ toJson(github.event.head_commit.message) }}, "short": false }],
|
||||
"footer": "${{ github.event.head_commit.committer.name }} at ${{ github.event.head_commit.timestamp }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
30
.github/workflows/tsconnect-pkg-publish.yml
vendored
30
.github/workflows/tsconnect-pkg-publish.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: "@tailscale/connect npm publish"
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "16.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Build package
|
||||
# Build with build_dist.sh to ensure that version information is embedded.
|
||||
# GOROOT is specified so that the Go/Wasm that is trigged by build-pk
|
||||
# also picks up our custom Go toolchain.
|
||||
run: |
|
||||
./build_dist.sh tailscale.com/cmd/tsconnect
|
||||
GOROOT="${HOME}/.cache/tailscale-go" ./tsconnect build-pkg
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.TSCONNECT_NPM_PUBLISH_AUTH_TOKEN }}
|
||||
run: ./tool/yarn --cwd ./cmd/tsconnect/pkg publish --access public
|
||||
49
.github/workflows/update-flake.yml
vendored
49
.github/workflows/update-flake.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: update-flake
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod. Also
|
||||
# allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/workflows/update-flakes.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tailscale:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@ad43dccb4d726ca8514126628bec209b8354b6dd #v4.1.4
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply@tailscale.com>
|
||||
committer: Flakes Updater <noreply@tailscale.com>
|
||||
branch: flakes
|
||||
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
title: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
reviewers: danderson
|
||||
55
.github/workflows/windows-race.yml
vendored
Normal file
55
.github/workflows/windows-race.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Windows race
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Test with -race flag
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test -race -bench . -benchtime 1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
55
.github/workflows/windows.yml
vendored
Normal file
55
.github/workflows/windows.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16.x
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Test
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test -bench . -benchtime 1x ./...
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
|
||||
45
.github/workflows/xe-experimental-vm-test.yml
vendored
Normal file
45
.github/workflows/xe-experimental-vm-test.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "integration-vms"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "tstest/integration/vms/**"
|
||||
release:
|
||||
types: [ created ]
|
||||
|
||||
jobs:
|
||||
experimental-linux-vm-test:
|
||||
# To set up a new runner, see tstest/integration/vms/runner.nix
|
||||
runs-on: [ self-hosted, linux, vm_integration_test ]
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Download VM Images
|
||||
run: go test ./tstest/integration/vms -run-vm-tests -run=Download -timeout=60m -no-s3
|
||||
env:
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
- name: Run VM tests
|
||||
run: go test ./tstest/integration/vms -v -run-vm-tests
|
||||
env:
|
||||
TMPDIR: "/tmp"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,7 +5,6 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.spk
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
@@ -22,9 +21,3 @@ cmd/tailscaled/tailscaled
|
||||
# direnv config, this may be different for other people so it's probably safer
|
||||
# to make this nonspecific.
|
||||
.envrc
|
||||
|
||||
# Ignore personal VS Code settings
|
||||
.vscode/
|
||||
|
||||
# Ignore direnv nix-shell environment cache
|
||||
.direnv/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.16
|
||||
56
Dockerfile
56
Dockerfile
@@ -1,13 +1,20 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
############################################################################
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
# WARNING: Tailscale is not yet officially supported in Docker,
|
||||
# Kubernetes, etc.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# It might work, but we don't regularly test it, and it's not as polished as
|
||||
# our currently supported platforms. This is provided for people who know
|
||||
# how Tailscale works and what they're doing.
|
||||
#
|
||||
# Our tracking bug for officially support container use cases is:
|
||||
# https://github.com/tailscale/tailscale/issues/504
|
||||
#
|
||||
# Also, see the various bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
@@ -16,11 +23,11 @@
|
||||
#
|
||||
# To build the Dockerfile:
|
||||
#
|
||||
# $ docker build -t tailscale/tailscale .
|
||||
# $ docker build -t tailscale:tailscale .
|
||||
#
|
||||
# To run the tailscaled agent:
|
||||
#
|
||||
# $ docker run -d --name=tailscaled -v /var/lib:/var/lib -v /dev/net/tun:/dev/net/tun --network=host --privileged tailscale/tailscale tailscaled
|
||||
# $ docker run -d --name=tailscaled -v /var/lib:/var/lib -v /dev/net/tun:/dev/net/tun --network=host --privileged tailscale:tailscale tailscaled
|
||||
#
|
||||
# To then log in:
|
||||
#
|
||||
@@ -31,25 +38,13 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.20-alpine AS build-env
|
||||
FROM golang:1.16-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Pre-build some stuff before the following COPY line invalidates the Docker cache.
|
||||
RUN go install \
|
||||
github.com/aws/aws-sdk-go-v2/aws \
|
||||
github.com/aws/aws-sdk-go-v2/config \
|
||||
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
nhooyr.io/websocket \
|
||||
github.com/mdlayher/netlink \
|
||||
golang.zx2c4.com/wireguard/device
|
||||
|
||||
COPY . .
|
||||
|
||||
# see build_docker.sh
|
||||
@@ -59,18 +54,13 @@ ARG VERSION_SHORT=""
|
||||
ENV VERSION_SHORT=$VERSION_SHORT
|
||||
ARG VERSION_GIT_HASH=""
|
||||
ENV VERSION_GIT_HASH=$VERSION_GIT_HASH
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN GOARCH=$TARGETARCH go install -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
|
||||
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
RUN go install -tags=xversion -ldflags="\
|
||||
-X tailscale.com/version.Long=$VERSION_LONG \
|
||||
-X tailscale.com/version.Short=$VERSION_SHORT \
|
||||
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/...
|
||||
|
||||
FROM alpine:3.11
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2
|
||||
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 ln -s /usr/local/bin/containerboot /tailscale/run.sh
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale Inc & AUTHORS.
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
73
Makefile
73
Makefile
@@ -1,79 +1,24 @@
|
||||
IMAGE_REPO ?= tailscale/tailscale
|
||||
SYNO_ARCH ?= "amd64"
|
||||
SYNO_DSM ?= "7"
|
||||
|
||||
usage:
|
||||
echo "See Makefile"
|
||||
|
||||
vet:
|
||||
./tool/go vet ./...
|
||||
|
||||
tidy:
|
||||
./tool/go mod tidy
|
||||
go vet ./...
|
||||
|
||||
updatedeps:
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
# it finds in its $$PATH is the right one.
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper
|
||||
go run github.com/tailscale/depaware --update tailscale.com/cmd/tailscaled
|
||||
go run github.com/tailscale/depaware --update tailscale.com/cmd/tailscale
|
||||
|
||||
depaware:
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
# it finds in its $$PATH is the right one.
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper
|
||||
go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscaled
|
||||
go run github.com/tailscale/depaware --check tailscale.com/cmd/tailscale
|
||||
|
||||
buildwindows:
|
||||
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
GOOS=windows GOARCH=amd64 go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
build386:
|
||||
GOOS=linux GOARCH=386 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
GOOS=linux GOARCH=386 go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildlinuxarm:
|
||||
GOOS=linux GOARCH=arm ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildwasm:
|
||||
GOOS=js GOARCH=wasm ./tool/go install ./cmd/tsconnect/wasm ./cmd/tailscale/cli
|
||||
|
||||
buildlinuxloong64:
|
||||
GOOS=linux GOARCH=loong64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildmultiarchimage:
|
||||
./build_docker.sh
|
||||
|
||||
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm
|
||||
check: staticcheck vet depaware buildwindows build386
|
||||
|
||||
staticcheck:
|
||||
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
|
||||
|
||||
spk:
|
||||
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o tailscale.spk --source=. --goarch=${SYNO_ARCH} --dsm-version=${SYNO_DSM}
|
||||
|
||||
spkall:
|
||||
mkdir -p spks
|
||||
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o spks --source=. --goarch=all --dsm-version=all
|
||||
|
||||
pushspk: spk
|
||||
echo "Pushing SPK to root@${SYNO_HOST} (env var SYNO_HOST) ..."
|
||||
scp tailscale.spk root@${SYNO_HOST}:
|
||||
ssh root@${SYNO_HOST} /usr/syno/bin/synopkg install tailscale.spk
|
||||
|
||||
publishdevimage:
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS=latest REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
|
||||
|
||||
publishdevoperator:
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS=latest REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
|
||||
go run honnef.co/go/tools/cmd/staticcheck -- $$(go list ./... | grep -v tempfork)
|
||||
|
||||
39
README.md
39
README.md
@@ -6,41 +6,26 @@ Private WireGuard® networks made easy
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains the majority of Tailscale's open source code.
|
||||
Notably, it includes the `tailscaled` daemon and
|
||||
the `tailscale` CLI tool. The `tailscaled` daemon runs on Linux, Windows,
|
||||
[macOS](https://tailscale.com/kb/1065/macos-variants/), and to varying degrees
|
||||
on FreeBSD and OpenBSD. The Tailscale iOS and Android apps use this repo's
|
||||
code, but this repo doesn't contain the mobile GUI code.
|
||||
This repository contains all the open source Tailscale client code and
|
||||
the `tailscaled` daemon and `tailscale` CLI tool. The `tailscaled`
|
||||
daemon runs primarily on Linux; it also works to varying degrees on
|
||||
FreeBSD, OpenBSD, Darwin, and Windows.
|
||||
|
||||
Other [Tailscale repos](https://github.com/orgs/tailscale/repositories) of note:
|
||||
|
||||
* the Android app is at https://github.com/tailscale/tailscale-android
|
||||
* the Synology package is at https://github.com/tailscale/tailscale-synology
|
||||
* the QNAP package is at https://github.com/tailscale/tailscale-qpkg
|
||||
* the Chocolatey packaging is at https://github.com/tailscale/tailscale-chocolatey
|
||||
|
||||
For background on which parts of Tailscale are open source and why,
|
||||
see [https://tailscale.com/opensource/](https://tailscale.com/opensource/).
|
||||
The Android app is at https://github.com/tailscale/tailscale-android
|
||||
|
||||
## Using
|
||||
|
||||
We serve packages for a variety of distros and platforms at
|
||||
[https://pkgs.tailscale.com](https://pkgs.tailscale.com/).
|
||||
We serve packages for a variety of distros at
|
||||
https://pkgs.tailscale.com .
|
||||
|
||||
## Other clients
|
||||
|
||||
The [macOS, iOS, and Windows clients](https://tailscale.com/download)
|
||||
use the code in this repository but additionally include small GUI
|
||||
wrappers. The GUI wrappers on non-open source platforms are themselves
|
||||
not open source.
|
||||
wrappers that are not open source.
|
||||
|
||||
## Building
|
||||
|
||||
We always require the latest Go release, currently Go 1.20. (While we build
|
||||
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
|
||||
required.)
|
||||
|
||||
```
|
||||
go install tailscale.com/cmd/tailscale{,d}
|
||||
```
|
||||
@@ -57,6 +42,11 @@ If your distro has conventions that preclude the use of
|
||||
`build_dist.sh`, please do the equivalent of what it does in your
|
||||
distro's way, so that bug reports contain useful version information.
|
||||
|
||||
We only guarantee to support the latest Go release and any Go beta or
|
||||
release candidate builds (currently Go 1.16) in module mode. It might
|
||||
work in earlier Go versions or in GOPATH mode, but we're making no
|
||||
effort to keep those working.
|
||||
|
||||
## Bugs
|
||||
|
||||
Please file any issues about this code or the hosted service on
|
||||
@@ -71,9 +61,6 @@ We require [Developer Certificate of
|
||||
Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
|
||||
`Signed-off-by` lines in commits.
|
||||
|
||||
See `git log` for our commit message style. It's basically the same as
|
||||
[Go's style](https://github.com/golang/go/wiki/CommitMessage).
|
||||
|
||||
## About Us
|
||||
|
||||
[Tailscale](https://tailscale.com/) is primarily developed by the
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.37.0
|
||||
1.13.0
|
||||
|
||||
498
api.md
498
api.md
@@ -3,7 +3,7 @@
|
||||
The Tailscale API is a (mostly) RESTful API. Typically, POST bodies should be JSON encoded and responses will be JSON encoded.
|
||||
|
||||
# Authentication
|
||||
Currently based on {some authentication method}. Visit the [admin console](https://login.tailscale.com/admin) and navigate to the `Settings` page. Generate an API Key and keep it safe. Provide the key as the user key in basic auth when making calls to Tailscale API endpoints (leave the password blank).
|
||||
Currently based on {some authentication method}. Visit the [admin panel](https://api.tailscale.com/admin) and navigate to the `Keys` page. Generate an API Key and keep it safe. Provide the key as the user key in basic auth when making calls to Tailscale API endpoints.
|
||||
|
||||
# APIs
|
||||
|
||||
@@ -13,25 +13,13 @@ Currently based on {some authentication method}. Visit the [admin console](https
|
||||
- Routes
|
||||
- [GET device routes](#device-routes-get)
|
||||
- [POST device routes](#device-routes-post)
|
||||
- Authorize machine
|
||||
- [POST device authorized](#device-authorized-post)
|
||||
- Tags
|
||||
- [POST device tags](#device-tags-post)
|
||||
- Key
|
||||
- [POST device key](#device-key-post)
|
||||
* **[Tailnets](#tailnet)**
|
||||
- ACLs
|
||||
- [GET tailnet ACL](#tailnet-acl-get)
|
||||
- [POST tailnet ACL](#tailnet-acl-post)
|
||||
- [POST tailnet ACL preview](#tailnet-acl-preview-post)
|
||||
- [POST tailnet ACL validate](#tailnet-acl-validate-post)
|
||||
- [POST tailnet ACL](#tailnet-acl-post): set ACL for a tailnet
|
||||
- [POST tailnet ACL preview](#tailnet-acl-preview-post): preview rule matches on an ACL for a resource
|
||||
- [Devices](#tailnet-devices)
|
||||
- [GET tailnet devices](#tailnet-devices-get)
|
||||
- [Keys](#tailnet-keys)
|
||||
- [GET tailnet keys](#tailnet-keys-get)
|
||||
- [POST tailnet key](#tailnet-keys-post)
|
||||
- [GET tailnet key](#tailnet-keys-key-get)
|
||||
- [DELETE tailnet key](#tailnet-keys-key-delete)
|
||||
- [DNS](#tailnet-dns)
|
||||
- [GET tailnet DNS nameservers](#tailnet-dns-nameservers-get)
|
||||
- [POST tailnet DNS nameservers](#tailnet-dns-nameservers-post)
|
||||
@@ -42,14 +30,14 @@ Currently based on {some authentication method}. Visit the [admin console](https
|
||||
|
||||
## Device
|
||||
<!-- TODO: description about what devices are -->
|
||||
Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id".
|
||||
Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id".
|
||||
You can use the deviceID to specify operations on a specific device, like retrieving its subnet routes.
|
||||
|
||||
To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network.
|
||||
To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network.
|
||||
Find the device you're looking for and get the "id" field.
|
||||
This is your deviceID.
|
||||
This is your deviceID.
|
||||
|
||||
<a name=device-get></a>
|
||||
<a name=device-get></div>
|
||||
|
||||
#### `GET /api/v2/device/:deviceid` - lists the details for a device
|
||||
Returns the details for the specified device.
|
||||
@@ -60,7 +48,7 @@ Use the `fields` query parameter to explicitly indicate which fields are returne
|
||||
##### Parameters
|
||||
##### Query Parameters
|
||||
`fields` - Controls which fields will be included in the returned response.
|
||||
Currently, supported options are:
|
||||
Currently, supported options are:
|
||||
* `all`: returns all fields in the response.
|
||||
* `default`: return all fields except:
|
||||
* `enabledRoutes`
|
||||
@@ -72,7 +60,7 @@ If more than one option is indicated, then the union is used.
|
||||
For example, for `fields=default,all`, all fields are returned.
|
||||
If the `fields` parameter is not provided, then the default option is used.
|
||||
|
||||
##### Example
|
||||
##### Example
|
||||
```
|
||||
GET /api/v2/device/12345
|
||||
curl 'https://api.tailscale.com/api/v2/device/12345?fields=all' \
|
||||
@@ -102,10 +90,10 @@ Response
|
||||
"nodeKey":"nodekey:user1-node-key",
|
||||
"blocksIncomingConnections":false,
|
||||
"enabledRoutes":[
|
||||
|
||||
|
||||
],
|
||||
"advertisedRoutes":[
|
||||
|
||||
|
||||
],
|
||||
"clientConnectivity": {
|
||||
"endpoints":[
|
||||
@@ -138,10 +126,10 @@ Response
|
||||
}
|
||||
```
|
||||
|
||||
<a name=device-delete></a>
|
||||
<a name=device-delete></div>
|
||||
|
||||
#### `DELETE /api/v2/device/:deviceID` - deletes the device from its tailnet
|
||||
Deletes the provided device from its tailnet.
|
||||
Deletes the provided device from its tailnet.
|
||||
The device must belong to the user's tailnet.
|
||||
Deleting shared/external devices is not supported.
|
||||
Supply the device of interest in the path using its ID.
|
||||
@@ -159,7 +147,7 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \
|
||||
|
||||
Response
|
||||
|
||||
If successful, the response should be empty:
|
||||
If successful, the response should be empty:
|
||||
```
|
||||
< HTTP/1.1 200 OK
|
||||
...
|
||||
@@ -167,7 +155,7 @@ If successful, the response should be empty:
|
||||
* Closing connection 0
|
||||
```
|
||||
|
||||
If the device is not owned by your tailnet:
|
||||
If the device is not owned by your tailnet:
|
||||
```
|
||||
< HTTP/1.1 501 Not Implemented
|
||||
...
|
||||
@@ -175,7 +163,7 @@ If the device is not owned by your tailnet:
|
||||
```
|
||||
|
||||
|
||||
<a name=device-routes-get></a>
|
||||
<a name=device-routes-get></div>
|
||||
|
||||
#### `GET /api/v2/device/:deviceID/routes` - fetch subnet routes that are advertised and enabled for a device
|
||||
|
||||
@@ -204,7 +192,7 @@ Response
|
||||
}
|
||||
```
|
||||
|
||||
<a name=device-routes-post></a>
|
||||
<a name=device-routes-post></div>
|
||||
|
||||
#### `POST /api/v2/device/:deviceID/routes` - set the subnet routes that are enabled for a device
|
||||
|
||||
@@ -245,102 +233,12 @@ Response
|
||||
}
|
||||
```
|
||||
|
||||
<a name=device-authorized-post></a>
|
||||
## Tailnet
|
||||
A tailnet is the name of your Tailscale network.
|
||||
You can find it in the top left corner of the [Admin Panel](https://login.tailscale.com/admin) beside the Tailscale logo.
|
||||
|
||||
#### `POST /api/v2/device/:deviceID/authorized` - authorize a device
|
||||
|
||||
Marks a device as authorized, for Tailnets where device authorization is required.
|
||||
|
||||
##### Parameters
|
||||
|
||||
###### POST Body
|
||||
`authorized` - whether the device is authorized; only `true` is currently supported.
|
||||
```
|
||||
{
|
||||
"authorized": true
|
||||
}
|
||||
```
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
curl 'https://api.tailscale.com/api/v2/device/11055/authorized' \
|
||||
-u "tskey-yourapikey123:" \
|
||||
--data-binary '{"authorized": true}'
|
||||
```
|
||||
|
||||
The response is 2xx on success. The response body is currently an empty JSON
|
||||
object.
|
||||
|
||||
<a name=device-tags-post></a>
|
||||
|
||||
#### `POST /api/v2/device/:deviceID/tags` - update tags on a device
|
||||
|
||||
Updates the tags set on a device.
|
||||
|
||||
##### Parameters
|
||||
|
||||
###### POST Body
|
||||
|
||||
`tags` - The new list of tags for the device.
|
||||
|
||||
```
|
||||
{
|
||||
"tags": ["tag:foo", "tag:bar"]
|
||||
}
|
||||
```
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
curl 'https://api.tailscale.com/api/v2/device/11055/tags' \
|
||||
-u "tskey-yourapikey123:" \
|
||||
--data-binary '{"tags": ["tag:foo", "tag:bar"]}'
|
||||
```
|
||||
|
||||
The response is 2xx on success. The response body is currently an empty JSON
|
||||
object.
|
||||
|
||||
<a name=device-key-post></a>
|
||||
|
||||
#### `POST /api/v2/device/:deviceID/key` - update device key
|
||||
|
||||
Allows for updating properties on the device key.
|
||||
|
||||
##### Parameters
|
||||
|
||||
###### POST Body
|
||||
|
||||
`keyExpiryDisabled`
|
||||
|
||||
- Provide `true` to disable the device's key expiry. The original key expiry time is still maintained. Upon re-enabling, the key will expire at that original time.
|
||||
- Provide `false` to enable the device's key expiry. Sets the key to expire at the original expiry time prior to disabling. The key may already have expired. In that case, the device must be re-authenticated.
|
||||
- Empty value will not change the key expiry.
|
||||
|
||||
```
|
||||
{
|
||||
"keyExpiryDisabled": true
|
||||
}
|
||||
```
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
curl 'https://api.tailscale.com/api/v2/device/11055/key' \
|
||||
-u "tskey-yourapikey123:" \
|
||||
--data-binary '{"keyExpiryDisabled": true}'
|
||||
```
|
||||
|
||||
The response is 2xx on success. The response body is currently an empty JSON
|
||||
object.
|
||||
|
||||
## Tailnet
|
||||
|
||||
A tailnet is your private network, composed of all the devices on it and their configuration. For more information on tailnets, see [our user-facing documentation](https://tailscale.com/kb/1136/tailnet/).
|
||||
|
||||
When making API requests, your tailnet is identified by the organization name. You can find it on the [Settings page](https://login.tailscale.com/admin/settings) of the admin console.
|
||||
|
||||
For example, if `alice@example.com` belongs to the `example.com` tailnet, they would use the following format for API calls:
|
||||
`alice@example.com` belongs to the `example.com` tailnet and would use the following format for API calls:
|
||||
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/...
|
||||
@@ -356,15 +254,10 @@ GET /api/v2/tailnet/alice@gmail.com/...
|
||||
curl https://api.tailscale.com/api/v2/tailnet/alice@gmail.com/...
|
||||
```
|
||||
|
||||
Alternatively, you can specify the value "-" to refer to the default tailnet of
|
||||
the authenticated user making the API call. For example:
|
||||
```
|
||||
GET /api/v2/tailnet/-/...
|
||||
curl https://api.tailscale.com/api/v2/tailnet/-/...
|
||||
```
|
||||
|
||||
Tailnets are a top-level resource. ACL is an example of a resource that is tied to a top-level tailnet.
|
||||
|
||||
For more information on Tailscale networks/tailnets, click [here](https://tailscale.com/kb/1064/invite-team-members).
|
||||
|
||||
### ACL
|
||||
|
||||
<a name=tailnet-acl-get></a>
|
||||
@@ -402,20 +295,20 @@ Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c"
|
||||
|
||||
// Example/default ACLs for unrestricted connections.
|
||||
{
|
||||
"tests": [],
|
||||
"Tests": [],
|
||||
// Declare static groups of users beyond those in the identity service.
|
||||
"groups": {
|
||||
"Groups": {
|
||||
"group:example": [
|
||||
"user1@example.com",
|
||||
"user2@example.com"
|
||||
],
|
||||
},
|
||||
// Declare convenient hostname aliases to use in place of IP addresses.
|
||||
"hosts": {
|
||||
"Hosts": {
|
||||
"example-host-1": "100.100.100.100",
|
||||
},
|
||||
// Access control lists.
|
||||
"acls": [
|
||||
"ACLs": [
|
||||
// Match absolutely everything. Comment out this section if you want
|
||||
// to define specific ACL restrictions.
|
||||
{
|
||||
@@ -485,8 +378,6 @@ Returns the updated ACL in JSON or HuJSON according to the `Accept` header on su
|
||||
###### Headers
|
||||
`If-Match` - A request header. Set this value to the ETag header provided in an `ACL GET` request to avoid missed updates.
|
||||
|
||||
A special value `ts-default` will ensure that ACL will be set only if current ACL is the default one (created automatically for each tailnet).
|
||||
|
||||
`Accept` - Sets the return type of the updated ACL. Response is parsed `JSON` if `application/json` is explicitly named, otherwise HuJSON will be returned.
|
||||
|
||||
###### POST Body
|
||||
@@ -494,14 +385,11 @@ A special value `ts-default` will ensure that ACL will be set only if current AC
|
||||
The POST body should be a JSON or [HuJSON](https://github.com/tailscale/hujson#hujson---human-json) formatted JSON object.
|
||||
An ACL policy may contain the following top-level properties:
|
||||
|
||||
* `groups` - Static groups of users which can be used for ACL rules.
|
||||
* `hosts` - Hostname aliases to use in place of IP addresses or subnets.
|
||||
* `acls` - Access control lists.
|
||||
* `tagOwners` - Defines who is allowed to use which tags.
|
||||
* `tests` - Run on ACL updates to check correct functionality of defined ACLs.
|
||||
* `autoApprovers` - Defines which users can advertise routes or exit nodes without further approval.
|
||||
* `ssh` - Configures access policy for Tailscale SSH.
|
||||
* `nodeAttrs` - Defines which devices can use certain features.
|
||||
* `Groups` - Static groups of users which can be used for ACL rules.
|
||||
* `Hosts` - Hostname aliases to use in place of IP addresses or subnets.
|
||||
* `ACLs` - Access control lists.
|
||||
* `TagOwners` - Defines who is allowed to use which tags.
|
||||
* `Tests` - Run on ACL updates to check correct functionality of defined ACLs.
|
||||
|
||||
See https://tailscale.com/kb/1018/acls for more information on those properties.
|
||||
|
||||
@@ -514,22 +402,22 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
|
||||
--data-binary '// Example/default ACLs for unrestricted connections.
|
||||
{
|
||||
// Declare tests to check functionality of ACL rules. User must be a valid user with registered machines.
|
||||
"tests": [
|
||||
// {"src": "user1@example.com", "accept": ["example-host-1:22"], "deny": ["example-host-2:100"]},
|
||||
"Tests": [
|
||||
// {"User": "user1@example.com", "Allow": ["example-host-1:22"], "Deny": ["example-host-2:100"]},
|
||||
],
|
||||
// Declare static groups of users beyond those in the identity service.
|
||||
"groups": {
|
||||
"Groups": {
|
||||
"group:example": [ "user1@example.com", "user2@example.com" ],
|
||||
},
|
||||
// Declare convenient hostname aliases to use in place of IP addresses.
|
||||
"hosts": {
|
||||
"Hosts": {
|
||||
"example-host-1": "100.100.100.100",
|
||||
},
|
||||
// Access control lists.
|
||||
"acls": [
|
||||
"ACLs": [
|
||||
// Match absolutely everything. Comment out this section if you want
|
||||
// to define specific ACL restrictions.
|
||||
{ "action": "accept", "users": ["*"], "ports": ["*:*"] },
|
||||
{ "Action": "accept", "Users": ["*"], "Ports": ["*:*"] },
|
||||
]
|
||||
}'
|
||||
```
|
||||
@@ -539,22 +427,22 @@ Response:
|
||||
// Example/default ACLs for unrestricted connections.
|
||||
{
|
||||
// Declare tests to check functionality of ACL rules. User must be a valid user with registered machines.
|
||||
"tests": [
|
||||
// {"src": "user1@example.com", "accept": ["example-host-1:22"], "deny": ["example-host-2:100"]},
|
||||
"Tests": [
|
||||
// {"User": "user1@example.com", "Allow": ["example-host-1:22"], "Deny": ["example-host-2:100"]},
|
||||
],
|
||||
// Declare static groups of users beyond those in the identity service.
|
||||
"groups": {
|
||||
"Groups": {
|
||||
"group:example": [ "user1@example.com", "user2@example.com" ],
|
||||
},
|
||||
// Declare convenient hostname aliases to use in place of IP addresses.
|
||||
"hosts": {
|
||||
"Hosts": {
|
||||
"example-host-1": "100.100.100.100",
|
||||
},
|
||||
// Access control lists.
|
||||
"acls": [
|
||||
"ACLs": [
|
||||
// Match absolutely everything. Comment out this section if you want
|
||||
// to define specific ACL restrictions.
|
||||
{ "action": "accept", "users": ["*"], "ports": ["*:*"] },
|
||||
{ "Action": "accept", "Users": ["*"], "Ports": ["*:*"] },
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -585,7 +473,7 @@ Determines what rules match for a user on an ACL without saving the ACL to the s
|
||||
###### Query Parameters
|
||||
`type` - can be 'user' or 'ipport'
|
||||
`previewFor` - if type=user, a user's email. If type=ipport, a IP address + port like "10.0.0.1:80".
|
||||
The provided ACL is queried with this parameter to determine which rules match.
|
||||
The provided ACL is queried with this paramater to determine which rules match.
|
||||
|
||||
###### POST Body
|
||||
ACL JSON or HuJSON (see https://tailscale.com/kb/1018/acls)
|
||||
@@ -597,22 +485,22 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/preview?previewFo
|
||||
--data-binary '// Example/default ACLs for unrestricted connections.
|
||||
{
|
||||
// Declare tests to check functionality of ACL rules. User must be a valid user with registered machines.
|
||||
"tests": [
|
||||
// {"src": "user1@example.com", "accept": ["example-host-1:22"], "deny": ["example-host-2:100"]},
|
||||
"Tests": [
|
||||
// {"User": "user1@example.com", "Allow": ["example-host-1:22"], "Deny": ["example-host-2:100"]},
|
||||
],
|
||||
// Declare static groups of users beyond those in the identity service.
|
||||
"groups": {
|
||||
"Groups": {
|
||||
"group:example": [ "user1@example.com", "user2@example.com" ],
|
||||
},
|
||||
// Declare convenient hostname aliases to use in place of IP addresses.
|
||||
"hosts": {
|
||||
"Hosts": {
|
||||
"example-host-1": "100.100.100.100",
|
||||
},
|
||||
// Access control lists.
|
||||
"acls": [
|
||||
"ACLs": [
|
||||
// Match absolutely everything. Comment out this section if you want
|
||||
// to define specific ACL restrictions.
|
||||
{ "action": "accept", "users": ["*"], "ports": ["*:*"] },
|
||||
{ "Action": "accept", "Users": ["*"], "Ports": ["*:*"] },
|
||||
]
|
||||
}'
|
||||
```
|
||||
@@ -622,72 +510,6 @@ Response:
|
||||
{"matches":[{"users":["*"],"ports":["*:*"],"lineNumber":19}],"user":"user1@example.com"}
|
||||
```
|
||||
|
||||
<a name=tailnet-acl-validate-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/acl/validate` - run validation tests against the tailnet's active ACL
|
||||
|
||||
This endpoint works in one of two modes:
|
||||
|
||||
1. with a request body that's a JSON array, the body is interpreted as ACL tests to run against the domain's current ACLs.
|
||||
2. with a request body that's a JSON object, the body is interpreted as a hypothetical new JSON (HuJSON) body with new ACLs, including any tests.
|
||||
|
||||
In either case, this endpoint does not modify the ACL in any way.
|
||||
|
||||
##### Parameters
|
||||
|
||||
###### POST Body
|
||||
|
||||
The POST body should be a JSON formatted array of ACL Tests.
|
||||
|
||||
See https://tailscale.com/kb/1018/acls for more information on the format of ACL tests.
|
||||
|
||||
##### Example with tests
|
||||
```
|
||||
POST /api/v2/tailnet/example.com/acl/validate
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
|
||||
-u "tskey-yourapikey123:" \
|
||||
--data-binary '
|
||||
[
|
||||
{"src": "user1@example.com", "accept": ["example-host-1:22"], "deny": ["example-host-2:100"]}
|
||||
]'
|
||||
```
|
||||
|
||||
##### Example with an ACL body
|
||||
```
|
||||
POST /api/v2/tailnet/example.com/acl/validate
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
|
||||
-u "tskey-yourapikey123:" \
|
||||
--data-binary '
|
||||
{
|
||||
"acls": [
|
||||
{ "action": "accept", "src": ["100.105.106.107"], "dst": ["1.2.3.4:*"] },
|
||||
],
|
||||
"tests", [
|
||||
{"src": "100.105.106.107", "allow": ["1.2.3.4:80"]}
|
||||
],
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
The HTTP status code will be 200 if the request was well formed and there were no server errors, even in the case of failing tests or an invalid ACL. Look at the response body to determine whether there was a problem within your ACL or tests.
|
||||
|
||||
If there's a problem, the response body will be a JSON object with a non-empty `message` property and optionally additional details in `data`:
|
||||
|
||||
```
|
||||
{
|
||||
"message":"test(s) failed",
|
||||
"data":[
|
||||
{
|
||||
"user":"user1@example.com",
|
||||
"errors":["address \"2.2.2.2:22\": want: Drop, got: Accept"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An empty body or a JSON object with no `message` is returned on success.
|
||||
|
||||
<a name=tailnet-devices></a>
|
||||
|
||||
### Devices
|
||||
@@ -695,7 +517,7 @@ An empty body or a JSON object with no `message` is returned on success.
|
||||
<a name=tailnet-devices-get></a>
|
||||
|
||||
#### <a name="getdevices"></a> `GET /api/v2/tailnet/:tailnet/devices` - list the devices for a tailnet
|
||||
Lists the devices in a tailnet.
|
||||
Lists the devices in a tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
Use the `fields` query parameter to explicitly indicate which fields are returned.
|
||||
|
||||
@@ -704,7 +526,7 @@ Use the `fields` query parameter to explicitly indicate which fields are returne
|
||||
|
||||
###### Query Parameters
|
||||
`fields` - Controls which fields will be included in the returned response.
|
||||
Currently, supported options are:
|
||||
Currently, supported options are:
|
||||
* `all`: Returns all fields in the response.
|
||||
* `default`: return all fields except:
|
||||
* `enabledRoutes`
|
||||
@@ -724,7 +546,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/devices' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response
|
||||
Response
|
||||
```
|
||||
{
|
||||
"devices":[
|
||||
@@ -774,182 +596,6 @@ Response
|
||||
}
|
||||
```
|
||||
|
||||
<a name=tailnet-keys></a>
|
||||
|
||||
### Keys
|
||||
|
||||
<a name=tailnet-keys-get></a>
|
||||
|
||||
#### `GET /api/v2/tailnet/:tailnet/keys` - list the keys for a tailnet
|
||||
|
||||
Returns a list of active keys for a tailnet
|
||||
for the user who owns the API key used to perform this query.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
|
||||
##### Returns
|
||||
|
||||
Returns a JSON object with the IDs of all active keys.
|
||||
This includes both API keys and also machine authentication keys.
|
||||
In the future, this may provide more information about each key than just the ID.
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
{"keys": [
|
||||
{"id": "kYKVU14CNTRL"},
|
||||
{"id": "k68VdZ3CNTRL"},
|
||||
{"id": "kJ9nq43CNTRL"},
|
||||
{"id": "kkThgj1CNTRL"}
|
||||
]}
|
||||
```
|
||||
|
||||
<a name=tailnet-keys-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/keys` - create a new key for a tailnet
|
||||
|
||||
Create a new key in a tailnet associated
|
||||
with the user who owns the API key used to perform this request.
|
||||
Supply the tailnet in the path.
|
||||
|
||||
##### Parameters
|
||||
|
||||
###### POST Body
|
||||
`capabilities` - A mapping of resources to permissible actions.
|
||||
|
||||
`expirySeconds` - (Optional) How long the key is valid for in seconds.
|
||||
Defaults to 90d.
|
||||
|
||||
```
|
||||
{
|
||||
"capabilities": {
|
||||
"devices": {
|
||||
"create": {
|
||||
"reusable": false,
|
||||
"ephemeral": false,
|
||||
"preauthorized": false,
|
||||
"tags": [
|
||||
"tag:example"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"expirySeconds": 1440
|
||||
}
|
||||
```
|
||||
|
||||
##### Returns
|
||||
|
||||
Returns a JSON object with the provided capabilities in addition to the
|
||||
generated key. The key should be recorded and kept safe and secure as it
|
||||
wields the capabilities specified in the request. The identity of the key
|
||||
is embedded in the key itself and can be used to perform operations on
|
||||
the key (e.g., revoking it or retrieving information about it).
|
||||
The full key can no longer be retrieved by the server.
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
echo '{
|
||||
"capabilities": {
|
||||
"devices": {
|
||||
"create": {
|
||||
"reusable": false,
|
||||
"ephemeral": false,
|
||||
"preauthorized": false,
|
||||
"tags": [ "tag:example" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}' | curl -X POST --data-binary @- https://api.tailscale.com/api/v2/tailnet/example.com/keys \
|
||||
-u "tskey-yourapikey123:" \
|
||||
-H "Content-Type: application/json" | jsonfmt
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
{
|
||||
"id": "k123456CNTRL",
|
||||
"key": "tskey-k123456CNTRL-abcdefghijklmnopqrstuvwxyz",
|
||||
"created": "2021-12-09T23:22:39Z",
|
||||
"expires": "2022-03-09T23:22:39Z",
|
||||
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false, "preauthorized": false, "tags": [ "tag:example" ]}}}
|
||||
}
|
||||
```
|
||||
|
||||
<a name=tailnet-keys-key-get></a>
|
||||
|
||||
#### `GET /api/v2/tailnet/:tailnet/keys/:keyid` - get information for a specific key
|
||||
|
||||
Returns a JSON object with information about specific key.
|
||||
Supply the tailnet and key ID of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
|
||||
##### Returns
|
||||
|
||||
Returns a JSON object with information about the key such as
|
||||
when it was created and when it expires.
|
||||
It also lists the capabilities associated with the key.
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
{
|
||||
"id": "k123456CNTRL",
|
||||
"created": "2022-05-05T18:55:44Z",
|
||||
"expires": "2022-08-03T18:55:44Z",
|
||||
"capabilities": {
|
||||
"devices": {
|
||||
"create": {
|
||||
"reusable": false,
|
||||
"ephemeral": true,
|
||||
"preauthorized": false,
|
||||
"tags": [
|
||||
"tag:bar",
|
||||
"tag:foo"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<a name=tailnet-keys-key-delete></a>
|
||||
|
||||
#### `DELETE /api/v2/tailnet/:tailnet/keys/:keyid` - delete a specific key
|
||||
|
||||
Deletes a specific key.
|
||||
Supply the tailnet and key ID of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
|
||||
##### Returns
|
||||
This reports status 200 upon success.
|
||||
|
||||
##### Example
|
||||
|
||||
```
|
||||
curl -X DELETE 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
<a name=tailnet-dns></a>
|
||||
|
||||
### DNS
|
||||
@@ -957,13 +603,13 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k12345
|
||||
<a name=tailnet-dns-nameservers-get></a>
|
||||
|
||||
#### `GET /api/v2/tailnet/:tailnet/dns/nameservers` - list the DNS nameservers for a tailnet
|
||||
Lists the DNS nameservers for a tailnet.
|
||||
Lists the DNS nameservers for a tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
|
||||
##### Example
|
||||
##### Example
|
||||
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/dns/nameservers
|
||||
@@ -971,7 +617,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response
|
||||
Response
|
||||
```
|
||||
{
|
||||
"dns": ["8.8.8.8"],
|
||||
@@ -981,7 +627,7 @@ Response
|
||||
<a name=tailnet-dns-nameservers-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/nameservers` - replaces the list of DNS nameservers for a tailnet
|
||||
Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user.
|
||||
Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user.
|
||||
Supply the tailnet of interest in the path.
|
||||
Note that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on).
|
||||
|
||||
@@ -995,7 +641,7 @@ Note that changing the list of DNS nameservers may also affect the status of Mag
|
||||
```
|
||||
|
||||
##### Returns
|
||||
Returns the new list of nameservers and the status of MagicDNS.
|
||||
Returns the new list of nameservers and the status of MagicDNS.
|
||||
|
||||
If all nameservers have been removed, MagicDNS will be automatically disabled (until explicitly turned back on by the user).
|
||||
|
||||
@@ -1039,31 +685,31 @@ Retrieves the DNS preferences that are currently set for the given tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
No parameters.
|
||||
|
||||
##### Example
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/dns/preferences
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \
|
||||
-u "tskey-yourapikey123:"
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
{
|
||||
"magicDNS":false,
|
||||
"magicDNS":false,
|
||||
}
|
||||
```
|
||||
|
||||
<a name=tailnet-dns-preferences-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet
|
||||
Replaces the DNS preferences for a tailnet, specifically, the MagicDNS setting.
|
||||
Note that MagicDNS is dependent on DNS servers.
|
||||
Note that MagicDNS is dependent on DNS servers.
|
||||
|
||||
If there is at least one DNS server, then MagicDNS can be enabled.
|
||||
If there is at least one DNS server, then MagicDNS can be enabled.
|
||||
Otherwise, it returns an error.
|
||||
Note that removing all nameservers will turn off MagicDNS.
|
||||
Note that removing all nameservers will turn off MagicDNS.
|
||||
To reenable it, nameservers must be added back, and MagicDNS must be explicitly turned on.
|
||||
|
||||
##### Parameters
|
||||
@@ -1094,7 +740,7 @@ If there are no DNS servers, it returns an error message:
|
||||
}
|
||||
```
|
||||
|
||||
If there are DNS servers:
|
||||
If there are DNS servers:
|
||||
```
|
||||
{
|
||||
"magicDNS":true,
|
||||
@@ -1103,8 +749,8 @@ If there are DNS servers:
|
||||
|
||||
<a name=tailnet-dns-searchpaths-get></a>
|
||||
|
||||
#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet
|
||||
Retrieves the list of search paths that is currently set for the given tailnet.
|
||||
#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet
|
||||
Retrieves the list of search paths that is currently set for the given tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
|
||||
@@ -1115,7 +761,7 @@ No parameters.
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/dns/searchpaths
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \
|
||||
-u "tskey-yourapikey123:"
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response:
|
||||
@@ -1127,7 +773,7 @@ Response:
|
||||
|
||||
<a name=tailnet-dns-searchpaths-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet
|
||||
Replaces the list of searchpaths with the list supplied by the user and returns an error otherwise.
|
||||
|
||||
##### Parameters
|
||||
@@ -1136,7 +782,7 @@ Replaces the list of searchpaths with the list supplied by the user and returns
|
||||
`searchPaths` - A list of searchpaths in JSON.
|
||||
```
|
||||
{
|
||||
"searchPaths": ["user1.example.com", "user2.example.com"]
|
||||
"searchPaths: ["user1.example.com", "user2.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2019 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package atomicfile contains code related to writing to filesystems
|
||||
// atomically.
|
||||
@@ -8,15 +9,16 @@
|
||||
package atomicfile // import "tailscale.com/atomicfile"
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// WriteFile writes data to filename+some suffix, then renames it
|
||||
// into filename. The perm argument is ignored on Windows.
|
||||
// into filename.
|
||||
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
f, err := ioutil.TempFile(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,14 +30,12 @@ else
|
||||
fi
|
||||
|
||||
long_suffix="$change_suffix-t$short_hash"
|
||||
MINOR="$major.$minor"
|
||||
SHORT="$MINOR.$patch"
|
||||
SHORT="$major.$minor.$patch"
|
||||
LONG="${SHORT}$long_suffix"
|
||||
GIT_HASH="$git_hash"
|
||||
|
||||
if [ "$1" = "shellvars" ]; then
|
||||
cat <<EOF
|
||||
VERSION_MINOR="$MINOR"
|
||||
VERSION_SHORT="$SHORT"
|
||||
VERSION_LONG="$LONG"
|
||||
VERSION_GIT_HASH="$GIT_HASH"
|
||||
@@ -45,25 +43,4 @@ EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tags=""
|
||||
ldflags="-X tailscale.com/version.longStamp=${LONG} -X tailscale.com/version.shortStamp=${SHORT}"
|
||||
|
||||
# build_dist.sh arguments must precede go build arguments.
|
||||
while [ "$#" -gt 1 ]; do
|
||||
case "$1" in
|
||||
--extra-small)
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
tags="${tags:+$tags,}ts_include_cli"
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec ./tool/go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
|
||||
exec go build -ldflags "-X tailscale.com/version.Long=${LONG} -X tailscale.com/version.Short=${SHORT} -X tailscale.com/version.GitCommit=${GIT_HASH}" "$@"
|
||||
|
||||
@@ -8,67 +8,27 @@
|
||||
#
|
||||
############################################################################
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
# WARNING: Tailscale is not yet officially supported in Docker,
|
||||
# Kubernetes, etc.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# It might work, but we don't regularly test it, and it's not as polished as
|
||||
# our currently supported platforms. This is provided for people who know
|
||||
# how Tailscale works and what they're doing.
|
||||
#
|
||||
# Our tracking bug for officially support container use cases is:
|
||||
# https://github.com/tailscale/tailscale/issues/504
|
||||
#
|
||||
# Also, see the various bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
|
||||
set -eu
|
||||
|
||||
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
|
||||
export PATH=$PWD/tool:$PATH
|
||||
|
||||
eval $(./build_dist.sh shellvars)
|
||||
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.16"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
TARGET="${TARGET:-${DEFAULT_TARGET}}"
|
||||
TAGS="${TAGS:-${DEFAULT_TAGS}}"
|
||||
BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
|
||||
case "$TARGET" in
|
||||
client)
|
||||
DEFAULT_REPOS="tailscale/tailscale"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled, \
|
||||
tailscale.com/cmd/containerboot:/usr/local/bin/containerboot" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
operator)
|
||||
DEFAULT_REPOS="tailscale/k8s-operator"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-operator:/usr/local/bin/operator" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
docker build \
|
||||
--build-arg VERSION_LONG=$VERSION_LONG \
|
||||
--build-arg VERSION_SHORT=$VERSION_SHORT \
|
||||
--build-arg VERSION_GIT_HASH=$VERSION_GIT_HASH \
|
||||
-t tailscale:tailscale .
|
||||
|
||||
163
chirp/chirp.go
163
chirp/chirp.go
@@ -1,163 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package chirp implements a client to communicate with the BIRD Internet
|
||||
// Routing Daemon.
|
||||
package chirp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Maximum amount of time we should wait when reading a response from BIRD.
|
||||
responseTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// New creates a BIRDClient.
|
||||
func New(socket string) (*BIRDClient, error) {
|
||||
return newWithTimeout(socket, responseTimeout)
|
||||
}
|
||||
|
||||
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
b := &BIRDClient{
|
||||
socket: socket,
|
||||
conn: conn,
|
||||
scanner: bufio.NewScanner(conn),
|
||||
timeNow: time.Now,
|
||||
timeout: timeout,
|
||||
}
|
||||
// Read and discard the first line as that is the welcome message.
|
||||
if _, err := b.readResponse(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// BIRDClient handles communication with the BIRD Internet Routing Daemon.
|
||||
type BIRDClient struct {
|
||||
socket string
|
||||
conn net.Conn
|
||||
scanner *bufio.Scanner
|
||||
timeNow func() time.Time
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Close closes the underlying connection to BIRD.
|
||||
func (b *BIRDClient) Close() error { return b.conn.Close() }
|
||||
|
||||
// DisableProtocol disables the provided protocol.
|
||||
func (b *BIRDClient) DisableProtocol(protocol string) error {
|
||||
out, err := b.exec("disable %s", protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(out, fmt.Sprintf("%s: already disabled", protocol)) {
|
||||
return nil
|
||||
} else if strings.Contains(out, fmt.Sprintf("%s: disabled", protocol)) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to disable %s: %v", protocol, out)
|
||||
}
|
||||
|
||||
// EnableProtocol enables the provided protocol.
|
||||
func (b *BIRDClient) EnableProtocol(protocol string) error {
|
||||
out, err := b.exec("enable %s", protocol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(out, fmt.Sprintf("%s: already enabled", protocol)) {
|
||||
return nil
|
||||
} else if strings.Contains(out, fmt.Sprintf("%s: enabled", protocol)) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to enable %s: %v", protocol, out)
|
||||
}
|
||||
|
||||
// BIRD CLI docs from https://bird.network.cz/?get_doc&v=20&f=prog-2.html#ss2.9
|
||||
|
||||
// Each session of the CLI consists of a sequence of request and replies,
|
||||
// slightly resembling the FTP and SMTP protocols.
|
||||
// Requests are commands encoded as a single line of text,
|
||||
// replies are sequences of lines starting with a four-digit code
|
||||
// followed by either a space (if it's the last line of the reply) or
|
||||
// a minus sign (when the reply is going to continue with the next line),
|
||||
// the rest of the line contains a textual message semantics of which depends on the numeric code.
|
||||
// If a reply line has the same code as the previous one and it's a continuation line,
|
||||
// the whole prefix can be replaced by a single white space character.
|
||||
//
|
||||
// Reply codes starting with 0 stand for ‘action successfully completed’ messages,
|
||||
// 1 means ‘table entry’, 8 ‘runtime error’ and 9 ‘syntax error’.
|
||||
|
||||
func (b *BIRDClient) exec(cmd string, args ...any) (string, error) {
|
||||
if err := b.conn.SetWriteDeadline(b.timeNow().Add(b.timeout)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fmt.Fprintf(b.conn, cmd, args...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fmt.Fprintln(b.conn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.readResponse()
|
||||
}
|
||||
|
||||
// hasResponseCode reports whether the provided byte slice is
|
||||
// prefixed with a BIRD response code.
|
||||
// Equivalent regex: `^\d{4}[ -]`.
|
||||
func hasResponseCode(s []byte) bool {
|
||||
if len(s) < 5 {
|
||||
return false
|
||||
}
|
||||
for _, b := range s[:4] {
|
||||
if '0' <= b && b <= '9' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return s[4] == ' ' || s[4] == '-'
|
||||
}
|
||||
|
||||
func (b *BIRDClient) readResponse() (string, error) {
|
||||
// Set the read timeout before we start reading anything.
|
||||
if err := b.conn.SetReadDeadline(b.timeNow().Add(b.timeout)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var resp strings.Builder
|
||||
var done bool
|
||||
for !done {
|
||||
if !b.scanner.Scan() {
|
||||
if err := b.scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("reading response from bird failed (EOF): %q", resp.String())
|
||||
}
|
||||
out := b.scanner.Bytes()
|
||||
if _, err := resp.Write(out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hasResponseCode(out) {
|
||||
done = out[4] == ' '
|
||||
}
|
||||
if !done {
|
||||
resp.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
return resp.String(), nil
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package chirp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeBIRD struct {
|
||||
net.Listener
|
||||
protocolsEnabled map[string]bool
|
||||
sock string
|
||||
}
|
||||
|
||||
func newFakeBIRD(t *testing.T, protocols ...string) *fakeBIRD {
|
||||
sock := filepath.Join(t.TempDir(), "sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pe := make(map[string]bool)
|
||||
for _, p := range protocols {
|
||||
pe[p] = false
|
||||
}
|
||||
return &fakeBIRD{
|
||||
Listener: l,
|
||||
protocolsEnabled: pe,
|
||||
sock: sock,
|
||||
}
|
||||
}
|
||||
|
||||
func (fb *fakeBIRD) listen() error {
|
||||
for {
|
||||
c, err := fb.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
go fb.handle(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (fb *fakeBIRD) handle(c net.Conn) {
|
||||
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
|
||||
sc := bufio.NewScanner(c)
|
||||
for sc.Scan() {
|
||||
cmd := sc.Text()
|
||||
args := strings.Split(cmd, " ")
|
||||
switch args[0] {
|
||||
case "enable":
|
||||
en, ok := fb.protocolsEnabled[args[1]]
|
||||
if !ok {
|
||||
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
|
||||
} else if en {
|
||||
fmt.Fprintf(c, "0010-%s: already enabled\n", args[1])
|
||||
} else {
|
||||
fmt.Fprintf(c, "0011-%s: enabled\n", args[1])
|
||||
}
|
||||
fmt.Fprintln(c, "0000 ")
|
||||
fb.protocolsEnabled[args[1]] = true
|
||||
case "disable":
|
||||
en, ok := fb.protocolsEnabled[args[1]]
|
||||
if !ok {
|
||||
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
|
||||
} else if !en {
|
||||
fmt.Fprintf(c, "0008-%s: already disabled\n", args[1])
|
||||
} else {
|
||||
fmt.Fprintf(c, "0009-%s: disabled\n", args[1])
|
||||
}
|
||||
fmt.Fprintln(c, "0000 ")
|
||||
fb.protocolsEnabled[args[1]] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChirp(t *testing.T) {
|
||||
fb := newFakeBIRD(t, "tailscale")
|
||||
defer fb.Close()
|
||||
go fb.listen()
|
||||
c, err := New(fb.sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DisableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DisableProtocol("tailscale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("rando"); err == nil {
|
||||
t.Fatalf("enabling %q succeeded", "rando")
|
||||
}
|
||||
if err := c.DisableProtocol("rando"); err == nil {
|
||||
t.Fatalf("disabling %q succeeded", "rando")
|
||||
}
|
||||
}
|
||||
|
||||
type hangingListener struct {
|
||||
net.Listener
|
||||
t *testing.T
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
sock string
|
||||
}
|
||||
|
||||
func newHangingListener(t *testing.T) *hangingListener {
|
||||
sock := filepath.Join(t.TempDir(), "sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &hangingListener{
|
||||
Listener: l,
|
||||
t: t,
|
||||
done: make(chan struct{}),
|
||||
sock: sock,
|
||||
}
|
||||
}
|
||||
|
||||
func (hl *hangingListener) Stop() {
|
||||
hl.Close()
|
||||
close(hl.done)
|
||||
hl.wg.Wait()
|
||||
}
|
||||
|
||||
func (hl *hangingListener) listen() error {
|
||||
for {
|
||||
c, err := hl.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
hl.wg.Add(1)
|
||||
go hl.handle(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (hl *hangingListener) handle(c net.Conn) {
|
||||
defer hl.wg.Done()
|
||||
|
||||
// Write our fake first line of response so that we get into the read loop
|
||||
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
hl.t.Logf("connection still hanging")
|
||||
case <-hl.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChirpTimeout(t *testing.T) {
|
||||
fb := newHangingListener(t)
|
||||
defer fb.Stop()
|
||||
go fb.listen()
|
||||
|
||||
c, err := newWithTimeout(fb.sock, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = c.EnableProtocol("tailscale")
|
||||
if err == nil {
|
||||
t.Fatal("got err=nil, want timeout")
|
||||
}
|
||||
if !os.IsTimeout(err) {
|
||||
t.Fatalf("got err=%v, want os.IsTimeout(err)=true", err)
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// ACLRow defines a rule that grants access by a set of users or groups to a set
|
||||
// of servers and ports.
|
||||
// Only one of Src/Dst or Users/Ports may be specified.
|
||||
type ACLRow struct {
|
||||
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||
Users []string `json:"users,omitempty"` // old name for src
|
||||
Ports []string `json:"ports,omitempty"` // old name for dst
|
||||
Src []string `json:"src,omitempty"`
|
||||
Dst []string `json:"dst,omitempty"`
|
||||
}
|
||||
|
||||
// ACLTest defines a test for your ACLs to prevent accidental exposure or
|
||||
// revoking of access to key servers and ports. Only one of Src or User may be
|
||||
// specified, and only one of Allow/Accept may be specified.
|
||||
type ACLTest struct {
|
||||
Src string `json:"src,omitempty"` // source
|
||||
User string `json:"user,omitempty"` // old name for source
|
||||
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
|
||||
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
|
||||
|
||||
Allow []string `json:"allow,omitempty"` // old name for accept
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
ACLs []ACLRow `json:"acls,omitempty"`
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
type ACL struct {
|
||||
ACL ACLDetails
|
||||
ETag string // to check with version on server
|
||||
}
|
||||
|
||||
// ACLHuJSON contains the HuJSON string of the ACL and metadata.
|
||||
type ACLHuJSON struct {
|
||||
ACL string
|
||||
Warnings []string
|
||||
ETag string // to check with version on server
|
||||
}
|
||||
|
||||
// ACL makes a call to the Tailscale server to get a JSON-parsed version of the ACL.
|
||||
// The JSON-parsed version of the ACL contains no comments as proper JSON does not support
|
||||
// comments.
|
||||
func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ACL: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
var aclDetails ACLDetails
|
||||
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acl = &ACL{
|
||||
ACL: aclDetails,
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// ACLHuJSON makes a call to the Tailscale server to get the ACL HuJSON and returns
|
||||
// it as a string.
|
||||
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
|
||||
// changes are allowing comments and trailing comments. See the following links for more info:
|
||||
// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format
|
||||
// https://github.com/tailscale/hujson
|
||||
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ACLHuJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/hujson")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
ACL []byte `json:"acl"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acl = &ACLHuJSON{
|
||||
ACL: string(data.ACL),
|
||||
Warnings: data.Warnings,
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// ACLTestFailureSummary specifies a user for which ACL tests
|
||||
// failed and the related user-friendly error messages.
|
||||
//
|
||||
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||
// JavaScript client to be rendered in the HTML.
|
||||
type ACLTestFailureSummary struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
|
||||
type ACLTestError struct {
|
||||
ErrResponse
|
||||
Data []ACLTestFailureSummary `json:"data"`
|
||||
}
|
||||
|
||||
func (e ACLTestError) Error() string {
|
||||
return fmt.Sprintf("%s, Data: %+v", e.ErrResponse.Error(), e.Data)
|
||||
}
|
||||
|
||||
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if avoidCollisions {
|
||||
req.Header.Set("If-Match", etag)
|
||||
}
|
||||
req.Header.Set("Accept", acceptHeader)
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// check if test error
|
||||
var ate ACLTestError
|
||||
if err := json.Unmarshal(b, &ate); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ate.Status = resp.StatusCode
|
||||
return nil, "", ate
|
||||
}
|
||||
return b, resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// SetACL sends a POST request to update the ACL according to the provided ACL object. If
|
||||
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||
// were missed.
|
||||
//
|
||||
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||
// Returns error if ACL has tests that fail.
|
||||
// Returns error if there are other errors with the ACL.
|
||||
func (c *Client) SetACL(ctx context.Context, acl ACL, avoidCollisions bool) (res *ACL, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetACL: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
var aclDetails ACLDetails
|
||||
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &ACL{
|
||||
ACL: aclDetails,
|
||||
ETag: etag,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetACLHuJSON sends a POST request to update the ACL according to the provided ACL object. If
|
||||
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||
// were missed.
|
||||
//
|
||||
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||
// Returns error if the HuJSON is invalid.
|
||||
// Returns error if ACL has tests that fail.
|
||||
// Returns error if there are other errors with the ACL.
|
||||
func (c *Client) SetACLHuJSON(ctx context.Context, acl ACLHuJSON, avoidCollisions bool) (res *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetACLHuJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
postData := []byte(acl.ACL)
|
||||
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/hujson")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = &ACLHuJSON{
|
||||
ACL: string(b),
|
||||
ETag: etag,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UserRuleMatch specifies the source users/groups/hosts that a rule targets
|
||||
// and the destination ports that they can access.
|
||||
// LineNumber is only useful for requests provided in HuJSON form.
|
||||
// While JSON requests will have LineNumber, the value is not useful.
|
||||
type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
}
|
||||
|
||||
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||
type ACLPreviewResponse struct {
|
||||
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
|
||||
Type string `json:"type"` // The request type: currently only "user" or "ipport".
|
||||
PreviewFor string `json:"previewFor"` // A specific user or ipport.
|
||||
}
|
||||
|
||||
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||
type ACLPreview struct {
|
||||
Matches []UserRuleMatch `json:"matches"`
|
||||
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
|
||||
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Add("type", previewType)
|
||||
q.Add("previewFor", previewFor)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
c.setAuth(req)
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// PreviewACLForUser determines what rules match a given ACL for a user.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLForUser: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLForIPPort determines what rules match a given ACL for a ipport.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.AddrPort) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLForIPPort: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLHuJSONForUser determines what rules match a given ACL for a user.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, user string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLHuJSONForUser: %w", err)
|
||||
}
|
||||
}()
|
||||
postData := []byte(acl.ACL)
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLHuJSONForIPPort determines what rules match a given ACL for a ipport.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, ipport string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLHuJSONForIPPort: %w", err)
|
||||
}
|
||||
}()
|
||||
postData := []byte(acl.ACL)
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateACLJSON takes in the given source and destination (in this situation,
|
||||
// it is assumed that you are checking whether the source can connect to destination)
|
||||
// and creates an ACLTest from that. It then sends the ACLTest to the control api acl
|
||||
// validate endpoint, where the test is run. It returns a nil ACLTestError pointer if
|
||||
// no test errors occur.
|
||||
func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (testErr *ACLTestError, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ValidateACLJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
|
||||
postData, err := json.Marshal(tests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("control api responded with %d status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The test ran without fail
|
||||
if len(b) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var res ACLTestError
|
||||
// The test returned errors.
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
// failed to unmarshal
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
|
||||
// Package apitype contains types for the Tailscale local API.
|
||||
package apitype
|
||||
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
|
||||
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
|
||||
type WhoIsResponse struct {
|
||||
Node *tailcfg.Node
|
||||
UserProfile *tailcfg.UserProfile
|
||||
|
||||
// Caps are extra capabilities that the remote Node has to this node.
|
||||
Caps []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// FileTarget is a node to which files can be sent, and the PeerAPI
|
||||
@@ -23,7 +18,7 @@ type WhoIsResponse struct {
|
||||
type FileTarget struct {
|
||||
Node *tailcfg.Node
|
||||
|
||||
// PeerAPI is the http://ip:port URL base of the node's PeerAPI,
|
||||
// PeerAPI is the http://ip:port URL base of the node's peer API,
|
||||
// without any path (not even a single slash).
|
||||
PeerAPIURL string
|
||||
}
|
||||
@@ -32,9 +27,3 @@ type WaitingFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// SetPushDeviceTokenRequest is the body POSTed to the LocalAPI endpoint /set-device-token.
|
||||
type SetPushDeviceTokenRequest struct {
|
||||
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
|
||||
PushDeviceToken string
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package apitype
|
||||
|
||||
type DNSConfig struct {
|
||||
Resolvers []DNSResolver `json:"resolvers"`
|
||||
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
|
||||
Routes map[string][]DNSResolver `json:"routes"`
|
||||
Domains []string `json:"domains"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Proxied bool `json:"proxied"`
|
||||
}
|
||||
|
||||
type DNSResolver struct {
|
||||
Addr string `json:"addr"`
|
||||
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
|
||||
type GetDevicesResponse struct {
|
||||
Devices []*Device `json:"devices"`
|
||||
}
|
||||
|
||||
type DerpRegion struct {
|
||||
Preferred bool `json:"preferred,omitempty"`
|
||||
LatencyMilliseconds float64 `json:"latencyMs"`
|
||||
}
|
||||
|
||||
type ClientConnectivity struct {
|
||||
Endpoints []string `json:"endpoints"`
|
||||
DERP string `json:"derp"`
|
||||
MappingVariesByDestIP opt.Bool `json:"mappingVariesByDestIP"`
|
||||
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
|
||||
DERPLatency map[string]DerpRegion `json:"latency"`
|
||||
ClientSupports map[string]opt.Bool `json:"clientSupports"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
// Addresses is a list of the devices's Tailscale IP addresses.
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
|
||||
ClientVersion string `json:"clientVersion"` // Empty for external devices.
|
||||
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
|
||||
OS string `json:"os"`
|
||||
Created string `json:"created"` // Empty for external devices.
|
||||
LastSeen string `json:"lastSeen"`
|
||||
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
|
||||
Expires string `json:"expires"`
|
||||
Authorized bool `json:"authorized"`
|
||||
IsExternal bool `json:"isExternal"`
|
||||
MachineKey string `json:"machineKey"` // Empty for external devices.
|
||||
NodeKey string `json:"nodeKey"`
|
||||
|
||||
// BlocksIncomingConnections is configured via the device's
|
||||
// Tailscale client preferences. This field is only reported
|
||||
// to the API starting with Tailscale 1.3.x clients.
|
||||
BlocksIncomingConnections bool `json:"blocksIncomingConnections"`
|
||||
|
||||
// The following fields are not included by default:
|
||||
|
||||
// EnabledRoutes are the previously-approved subnet routes
|
||||
// (e.g. "192.168.4.16/24", "10.5.2.4/32").
|
||||
EnabledRoutes []string `json:"enabledRoutes"` // Empty for external devices.
|
||||
// AdvertisedRoutes are the subnets (both enabled and not enabled)
|
||||
// being requested from the node.
|
||||
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
|
||||
|
||||
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
|
||||
}
|
||||
|
||||
// DeviceFieldsOpts determines which fields should be returned in the response.
|
||||
//
|
||||
// Please only use DeviceAllFields and DeviceDefaultFields.
|
||||
// Other DeviceFieldsOpts are not supported.
|
||||
//
|
||||
// TODO: Support other DeviceFieldsOpts.
|
||||
// In the future, users should be able to create their own DeviceFieldsOpts
|
||||
// as valid arguments by setting the fields they want returned to a "non-nil"
|
||||
// value. For example, DeviceFieldsOpts{NodeID: "true"} should only return NodeIDs.
|
||||
type DeviceFieldsOpts Device
|
||||
|
||||
func (d *DeviceFieldsOpts) addFieldsToQueryParameter() string {
|
||||
if d == DeviceDefaultFields || d == nil {
|
||||
return "default"
|
||||
}
|
||||
if d == DeviceAllFields {
|
||||
return "all"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
DeviceAllFields = &DeviceFieldsOpts{}
|
||||
|
||||
// DeviceDefaultFields specifies that the following fields are returned:
|
||||
// Addresses, NodeID, User, Name, Hostname, ClientVersion, UpdateAvailable,
|
||||
// OS, Created, LastSeen, KeyExpiryDisabled, Expires, Authorized, IsExternal
|
||||
// MachineKey, NodeKey, BlocksIncomingConnections.
|
||||
DeviceDefaultFields = &DeviceFieldsOpts{}
|
||||
)
|
||||
|
||||
// Devices retrieves the list of devices for a tailnet.
|
||||
//
|
||||
// See the Device structure for the list of fields hidden for external devices.
|
||||
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||
// Other values are currently undefined.
|
||||
func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceList []*Device, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Devices: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Add fields.
|
||||
fieldStr := fields.addFieldsToQueryParameter()
|
||||
q := req.URL.Query()
|
||||
q.Add("fields", fieldStr)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var devices GetDevicesResponse
|
||||
err = json.Unmarshal(b, &devices)
|
||||
return devices.Devices, err
|
||||
}
|
||||
|
||||
// Device retrieved the details for a specific device.
|
||||
//
|
||||
// See the Device structure for the list of fields hidden for an external device.
|
||||
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||
// Other values are currently undefined.
|
||||
func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFieldsOpts) (device *Device, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Device: %w", err)
|
||||
}
|
||||
}()
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add fields.
|
||||
fieldStr := fields.addFieldsToQueryParameter()
|
||||
q := req.URL.Query()
|
||||
q.Add("fields", fieldStr)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &device)
|
||||
return device, err
|
||||
}
|
||||
|
||||
// DeleteDevice deletes the specified device from the Client's tailnet.
|
||||
// NOTE: Only devices that belong to the Client's tailnet can be deleted.
|
||||
// Deleting external devices is not supported.
|
||||
func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DeleteDevice: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeDevice marks a device as authorized.
|
||||
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTags updates the ACL tags on a device.
|
||||
func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) error {
|
||||
params := &struct {
|
||||
Tags []string `json:"tags"`
|
||||
}{Tags: tags}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/tags", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
)
|
||||
|
||||
// DNSNameServers is returned when retrieving the list of nameservers.
|
||||
// It is also the structure provided when setting nameservers.
|
||||
type DNSNameServers struct {
|
||||
DNS []string `json:"dns"` // DNS name servers
|
||||
}
|
||||
|
||||
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
|
||||
//
|
||||
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
|
||||
type DNSNameServersPostResponse struct {
|
||||
DNS []string `json:"dns"` // DNS name servers
|
||||
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||
}
|
||||
|
||||
// DNSSearchpaths is the list of search paths for a given domain.
|
||||
type DNSSearchPaths struct {
|
||||
SearchPaths []string `json:"searchPaths"` // DNS search paths
|
||||
}
|
||||
|
||||
// DNSPreferences is the preferences set for a given tailnet.
|
||||
//
|
||||
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
|
||||
// there must be at least one nameserver. When all nameservers are removed,
|
||||
// MagicDNS is disabled.
|
||||
type DNSPreferences struct {
|
||||
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||
}
|
||||
|
||||
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
data, err := json.Marshal(&postData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// DNSConfig retrieves the DNSConfig settings for a domain.
|
||||
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp apitype.DNSConfig
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return &dnsResp, err
|
||||
}
|
||||
|
||||
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
|
||||
}
|
||||
}()
|
||||
var dnsResp apitype.DNSConfig
|
||||
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return &dnsResp, err
|
||||
}
|
||||
|
||||
// NameServers retrieves the list of nameservers set for a domain.
|
||||
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.NameServers: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "nameservers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp DNSNameServers
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.DNS, err
|
||||
}
|
||||
|
||||
// SetNameServers sets the list of nameservers for a tailnet to the list provided
|
||||
// by the user.
|
||||
//
|
||||
// It returns the new list of nameservers and the MagicDNS status in case it was
|
||||
// affected by the change. For example, removing all nameservers will turn off
|
||||
// MagicDNS.
|
||||
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSNameServers{DNS: nameservers}
|
||||
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// DNSPreferences retrieves the DNS preferences set for a tailnet.
|
||||
//
|
||||
// It returns the status of MagicDNS.
|
||||
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "preferences")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// SetDNSPreferences sets the DNS preferences for a tailnet.
|
||||
//
|
||||
// MagicDNS can only be enabled when there is at least one nameserver provided.
|
||||
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
|
||||
// unless explicitly enabled by a user again.
|
||||
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSPreferences{MagicDNS: magicDNS}
|
||||
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// SearchPaths retrieves the list of searchpaths set for a tailnet.
|
||||
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "searchpaths")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp *DNSSearchPaths
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.SearchPaths, err
|
||||
}
|
||||
|
||||
// SetSearchPaths sets the list of searchpaths for a tailnet.
|
||||
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
|
||||
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp DNSSearchPaths
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.SearchPaths, err
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The servetls program shows how to run an HTTPS server
|
||||
// using a Tailscale cert via LetsEncrypt.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: tailscale.GetCertificate,
|
||||
},
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
|
||||
}),
|
||||
}
|
||||
log.Printf("Running TLS server on :443 ...")
|
||||
log.Fatal(s.ListenAndServeTLS("", ""))
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Key represents a Tailscale API or auth key.
|
||||
type Key struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// KeyCapabilities are the capabilities of a Key.
|
||||
type KeyCapabilities struct {
|
||||
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
|
||||
}
|
||||
|
||||
// KeyDeviceCapabilities are the device-related capabilities of a Key.
|
||||
type KeyDeviceCapabilities struct {
|
||||
Create KeyDeviceCreateCapabilities `json:"create"`
|
||||
}
|
||||
|
||||
// KeyDeviceCreateCapabilities are the device creation capabilities of a Key.
|
||||
type KeyDeviceCreateCapabilities struct {
|
||||
Reusable bool `json:"reusable"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
Preauthorized bool `json:"preauthorized"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// Keys returns the list of keys for the current user.
|
||||
func (c *Client) Keys(ctx context.Context) ([]string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var keys struct {
|
||||
Keys []*Key `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := make([]string, 0, len(keys.Keys))
|
||||
for _, k := range keys.Keys {
|
||||
ret = append(ret, k.ID)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// CreateKey creates a new key for the current user. Currently, only auth keys
|
||||
// can be created. Returns the key itself, which cannot be retrieved again
|
||||
// later, and the key metadata.
|
||||
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (string, *Key, error) {
|
||||
keyRequest := struct {
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
}{caps}
|
||||
bs, err := json.Marshal(keyRequest)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key struct {
|
||||
Key
|
||||
Secret string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &key); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return key.Secret, &key.Key, nil
|
||||
}
|
||||
|
||||
// Key returns the metadata for the given key ID. Currently, capabilities are
|
||||
// only returned for auth keys, API keys only return general metadata.
|
||||
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key Key
|
||||
if err := json.Unmarshal(b, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DeleteKey deletes the key with the given ID.
|
||||
func (c *Client) DeleteKey(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
if sc != nil {
|
||||
t.Errorf("want nil for null")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("reading null: %v", err)
|
||||
}
|
||||
|
||||
sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`))
|
||||
if err != nil {
|
||||
t.Errorf("reading object: %v", err)
|
||||
} else if sc == nil {
|
||||
t.Errorf("want non-nil for object")
|
||||
} else if sc.TCP == nil {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !go1.20
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_20_to_compile_Tailscale()
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// Routes contains the lists of subnet routes that are currently advertised by a device,
|
||||
// as well as the subnets that are enabled to be routed by the device.
|
||||
type Routes struct {
|
||||
AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
|
||||
EnabledRoutes []netip.Prefix `json:"enabledRoutes"`
|
||||
}
|
||||
|
||||
// Routes retrieves the list of subnet routes that have been enabled for a device.
|
||||
// The routes that are returned are not necessarily advertised by the device,
|
||||
// they have only been preapproved.
|
||||
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Routes: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var sr Routes
|
||||
err = json.Unmarshal(b, &sr)
|
||||
return &sr, err
|
||||
}
|
||||
|
||||
type postRoutesParams struct {
|
||||
Routes []netip.Prefix `json:"routes"`
|
||||
}
|
||||
|
||||
// SetRoutes updates the list of subnets that are enabled for a device.
|
||||
// Subnets must be parsable by net/netip.ParsePrefix.
|
||||
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
|
||||
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
|
||||
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip.Prefix) (routes *Routes, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
|
||||
}
|
||||
}()
|
||||
params := &postRoutesParams{Routes: subnets}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var srr *Routes
|
||||
if err := json.Unmarshal(b, &srr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srr, err
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
|
||||
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.setAuth(req)
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,157 +1,295 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
// Package tailscale contains Go clients for the Tailscale LocalAPI and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// Warning: this package is in development and makes no API compatibility
|
||||
// promises as of 2022-04-29. It is subject to change at any time.
|
||||
// Package tailscale contains Tailscale client code.
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||
// for now. It was added 2022-04-29 when it was moved to this git repo
|
||||
// and will be removed when the public API has settled.
|
||||
// TailscaledSocket is the tailscaled Unix socket.
|
||||
var TailscaledSocket = paths.DefaultTailscaledSocket()
|
||||
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
var tsClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr != "local-tailscaled.sock:80" {
|
||||
return nil, fmt.Errorf("unexpected URL address %q", addr)
|
||||
}
|
||||
if TailscaledSocket == paths.DefaultTailscaledSocket() {
|
||||
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
|
||||
// a TCP server on a random port, find the random port. For HTTP connections,
|
||||
// we don't send the token. It gets added in an HTTP Basic-Auth header.
|
||||
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
return safesocket.Connect(TailscaledSocket, 41112)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
//
|
||||
// TODO(bradfitz): remove this after the we're happy with the public API.
|
||||
var I_Acknowledge_This_API_Is_Unstable = false
|
||||
|
||||
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
|
||||
|
||||
const defaultAPIBase = "https://api.tailscale.com"
|
||||
|
||||
// maxSize is the maximum read size (10MB) of responses from the server.
|
||||
const maxReadSize = 10 << 20
|
||||
|
||||
// Client makes API calls to the Tailscale control plane API server.
|
||||
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
|
||||
//
|
||||
// Use NewClient to instantiate one. Exported fields should be set before
|
||||
// the client is used and not changed thereafter.
|
||||
type Client struct {
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
tailnet string
|
||||
// auth is the authentication method to use for this client.
|
||||
// nil means none, which generally won't work, but won't crash.
|
||||
auth AuthMethod
|
||||
|
||||
// BaseURL optionally specifies an alternate API server to use.
|
||||
// If empty, "https://api.tailscale.com" is used.
|
||||
BaseURL string
|
||||
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func (c *Client) baseURL() string {
|
||||
if c.BaseURL != "" {
|
||||
return c.BaseURL
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// AuthMethod is the interface for API authentication methods.
|
||||
// The hostname must be "local-tailscaled.sock", even though it
|
||||
// doesn't actually do any DNS lookup. The actual means of connecting to and
|
||||
// authenticating to the local Tailscale daemon vary by platform.
|
||||
//
|
||||
// Most users will use AuthKey.
|
||||
type AuthMethod interface {
|
||||
modifyRequest(req *http.Request)
|
||||
}
|
||||
|
||||
// APIKey is an AuthMethod for NewClient that authenticates requests
|
||||
// using an authkey.
|
||||
type APIKey string
|
||||
|
||||
func (ak APIKey) modifyRequest(req *http.Request) {
|
||||
req.SetBasicAuth(string(ak), "")
|
||||
}
|
||||
|
||||
func (c *Client) setAuth(r *http.Request) {
|
||||
if c.auth != nil {
|
||||
c.auth.modifyRequest(r)
|
||||
// DoLocalRequest may mutate the request to add Authorization headers.
|
||||
func DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
return tsClient.Do(req)
|
||||
}
|
||||
|
||||
// NewClient is a convenience method for instantiating a new Client.
|
||||
//
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
// If httpClient is nil, then http.DefaultClient is used.
|
||||
// "api.tailscale.com" is set as the BaseURL for the returned client
|
||||
// and can be changed manually by the user.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
}
|
||||
type errorJSON struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
func (c *Client) Tailnet() string { return c.tailnet }
|
||||
|
||||
// Do sends a raw HTTP request, after adding any authentication headers.
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
// bestError returns either err, or if body contains a valid JSON
|
||||
// object of type errorJSON, its non-empty error body.
|
||||
func bestError(err error, body []byte) error {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return errors.New(j.Error)
|
||||
}
|
||||
c.setAuth(req)
|
||||
return c.httpClient().Do(req)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response. Limit the response to 10MB.
|
||||
body := io.LimitReader(resp.Body, maxReadSize+1)
|
||||
b, err := io.ReadAll(body)
|
||||
if len(b) > maxReadSize {
|
||||
err = errors.New("API response too large")
|
||||
res, err := DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, resp, err
|
||||
defer res.Body.Close()
|
||||
slurp, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err := fmt.Errorf("HTTP %s: %s (expected %v)", res.Status, slurp, wantStatus)
|
||||
return nil, bestError(err, slurp)
|
||||
}
|
||||
return slurp, nil
|
||||
}
|
||||
|
||||
// ErrResponse is the HTTP error returned by the Tailscale server.
|
||||
type ErrResponse struct {
|
||||
Status int
|
||||
Message string
|
||||
func get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
|
||||
func (e ErrResponse) Error() string {
|
||||
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := new(apitype.WhoIsResponse)
|
||||
if err := json.Unmarshal(body, r); err != nil {
|
||||
if max := 200; len(body) > max {
|
||||
body = append(body[:max], "..."...)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||||
func Goroutines(ctx context.Context) ([]byte, error) {
|
||||
return get200(ctx, "/localapi/v0/goroutines")
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
func BugReport(ctx context.Context, note string) (string, error) {
|
||||
body, err := send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return status(ctx, "")
|
||||
}
|
||||
|
||||
// StatusWithPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return status(ctx, "?peers=false")
|
||||
}
|
||||
|
||||
func status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/status"+queryString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := new(ipnstate.Status)
|
||||
if err := json.Unmarshal(body, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/files/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wfs []apitype.WaitingFile
|
||||
if err := json.Unmarshal(body, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||||
_, err := send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
res, err := DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if res.ContentLength == -1 {
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("unexpected chunking")
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||||
}
|
||||
return res.Body, res.ContentLength, nil
|
||||
}
|
||||
|
||||
func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/file-targets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fts []apitype.FileTarget
|
||||
if err := json.Unmarshal(body, &fts); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return fts, nil
|
||||
}
|
||||
|
||||
func CheckIPForwarding(ctx context.Context) error {
|
||||
body, err := get200(ctx, "/localapi/v0/check-ip-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
var jres struct {
|
||||
Warning string
|
||||
}
|
||||
if err := json.Unmarshal(body, &jres); err != nil {
|
||||
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/prefs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
mpj, err := json.Marshal(mp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func Logout(ctx context.Context) error {
|
||||
_, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||||
// the provided TXT value. The intended use case is answering
|
||||
// LetsEncrypt/ACME dns-01 challenges.
|
||||
//
|
||||
// The control plane will only permit SetDNS requests with very
|
||||
// specific names and values. The name should be
|
||||
// "_acme-challenge." + your node's MagicDNS name. It's expected that
|
||||
// clients cache the certs from LetsEncrypt (or whichever CA is
|
||||
// providing them) and only request new ones as needed; the control plane
|
||||
// rate limits SetDNS requests.
|
||||
//
|
||||
// This is a low-level interface; it's expected that most Tailscale
|
||||
// users use a higher level interface to getting/using TLS
|
||||
// certificates.
|
||||
func SetDNS(ctx context.Context, name, value string) error {
|
||||
v := url.Values{}
|
||||
v.Set("name", name)
|
||||
v.Set("value", value)
|
||||
_, err := send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
|
||||
// It is intended to be used with netcheck to see availability of DERPs.
|
||||
func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
var derpMap tailcfg.DERPMap
|
||||
res, err := send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(res, &derpMap); err != nil {
|
||||
return nil, fmt.Errorf("invalid derp map json: %w", err)
|
||||
}
|
||||
return &derpMap, nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Program addlicense adds a license header to a file.
|
||||
// It is intended for use with 'go generate',
|
||||
@@ -14,24 +15,26 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
year = flag.Int("year", 0, "copyright year")
|
||||
file = flag.String("file", "", "file to modify")
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
usage: addlicense -file FILE <subcommand args...>
|
||||
usage: addlicense -year YEAR -file FILE <subcommand args...>
|
||||
`[1:])
|
||||
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
addlicense adds a Tailscale license to the beginning of file.
|
||||
addlicense adds a Tailscale license to the beginning of file,
|
||||
using year as the copyright year.
|
||||
|
||||
It is intended for use with 'go generate', so it also runs a subcommand,
|
||||
which presumably creates the file.
|
||||
|
||||
Sample usage:
|
||||
|
||||
addlicense -file pull_strings.go stringer -type=pull
|
||||
addlicense -year 2021 -file pull_strings.go stringer -type=pull
|
||||
`[1:])
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -51,7 +54,7 @@ func main() {
|
||||
check(err)
|
||||
f, err := os.OpenFile(*file, os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
check(err)
|
||||
_, err = fmt.Fprint(f, license)
|
||||
_, err = fmt.Fprintf(f, license, *year)
|
||||
check(err)
|
||||
_, err = f.Write(b)
|
||||
check(err)
|
||||
@@ -67,7 +70,8 @@ func check(err error) {
|
||||
}
|
||||
|
||||
var license = `
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) %d Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
`[1:]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Cloner is a tool to automate the creation of a Clone method.
|
||||
//
|
||||
@@ -16,16 +17,21 @@ import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/codegen"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
var (
|
||||
flagTypes = flag.String("type", "", "comma-separated list of types; required")
|
||||
flagOutput = flag.String("output", "", "output file; required")
|
||||
flagBuildTags = flag.String("tags", "", "compiler build tags to apply")
|
||||
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
|
||||
)
|
||||
@@ -40,28 +46,64 @@ func main() {
|
||||
}
|
||||
typeNames := strings.Split(*flagTypes, ",")
|
||||
|
||||
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedName,
|
||||
Tests: false,
|
||||
}
|
||||
if *flagBuildTags != "" {
|
||||
cfg.BuildFlags = []string{"-tags=" + *flagBuildTags}
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
it := codegen.NewImportTracker(pkg.Types)
|
||||
if len(pkgs) != 1 {
|
||||
log.Fatalf("wrong number of packages: %d", len(pkgs))
|
||||
}
|
||||
pkg := pkgs[0]
|
||||
buf := new(bytes.Buffer)
|
||||
imports := make(map[string]struct{})
|
||||
for _, typeName := range typeNames {
|
||||
typ, ok := namedTypes[typeName]
|
||||
if !ok {
|
||||
found := false
|
||||
for _, file := range pkg.Syntax {
|
||||
//var fbuf bytes.Buffer
|
||||
//ast.Fprint(&fbuf, pkg.Fset, file, nil)
|
||||
//fmt.Println(fbuf.String())
|
||||
|
||||
for _, d := range file.Decls {
|
||||
decl, ok := d.(*ast.GenDecl)
|
||||
if !ok || decl.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, s := range decl.Specs {
|
||||
spec, ok := s.(*ast.TypeSpec)
|
||||
if !ok || spec.Name.Name != typeName {
|
||||
continue
|
||||
}
|
||||
typeNameObj := pkg.TypesInfo.Defs[spec.Name]
|
||||
typ, ok := typeNameObj.Type().(*types.Named)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pkg := typeNameObj.Pkg()
|
||||
gen(buf, imports, typeName, typ, pkg)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
log.Fatalf("could not find type %s", typeName)
|
||||
}
|
||||
gen(buf, it, typ)
|
||||
}
|
||||
|
||||
w := func(format string, args ...any) {
|
||||
w := func(format string, args ...interface{}) {
|
||||
fmt.Fprintf(buf, format+"\n", args...)
|
||||
}
|
||||
if *flagCloneFunc {
|
||||
w("// Clone duplicates src into dst and reports whether it succeeded.")
|
||||
w("// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,")
|
||||
w("// where T is one of %s.", *flagTypes)
|
||||
w("func Clone(dst, src any) bool {")
|
||||
w("func Clone(dst, src interface{}) bool {")
|
||||
w(" switch src := src.(type) {")
|
||||
for _, typeName := range typeNames {
|
||||
w(" case *%s:", typeName)
|
||||
@@ -78,117 +120,156 @@ func main() {
|
||||
w(" return false")
|
||||
w("}")
|
||||
}
|
||||
cloneOutput := pkg.Name + "_clone.go"
|
||||
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
|
||||
|
||||
contents := new(bytes.Buffer)
|
||||
fmt.Fprintf(contents, header, *flagTypes, pkg.Name)
|
||||
fmt.Fprintf(contents, "import (\n")
|
||||
for s := range imports {
|
||||
fmt.Fprintf(contents, "\t%q\n", s)
|
||||
}
|
||||
fmt.Fprintf(contents, ")\n\n")
|
||||
contents.Write(buf.Bytes())
|
||||
|
||||
out, err := format.Source(contents.Bytes())
|
||||
if err != nil {
|
||||
log.Fatalf("%s, in source:\n%s", err, contents.Bytes())
|
||||
}
|
||||
|
||||
output := *flagOutput
|
||||
if output == "" {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := ioutil.WriteFile(output, out, 0644); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
t, ok := typ.Underlying().(*types.Struct)
|
||||
if !ok {
|
||||
return
|
||||
const header = `// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Code generated by tailscale.com/cmd/cloner -type %s; DO NOT EDIT.
|
||||
|
||||
package %s
|
||||
|
||||
`
|
||||
|
||||
func gen(buf *bytes.Buffer, imports map[string]struct{}, name string, typ *types.Named, thisPkg *types.Package) {
|
||||
pkgQual := func(pkg *types.Package) string {
|
||||
if thisPkg == pkg {
|
||||
return ""
|
||||
}
|
||||
imports[pkg.Path()] = struct{}{}
|
||||
return pkg.Name()
|
||||
}
|
||||
importedName := func(t types.Type) string {
|
||||
return types.TypeString(t, pkgQual)
|
||||
}
|
||||
|
||||
name := typ.Obj().Name()
|
||||
fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name)
|
||||
fmt.Fprintf(buf, "// The result aliases no memory with the original.\n")
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", name, name)
|
||||
writef := func(format string, args ...any) {
|
||||
fmt.Fprintf(buf, "\t"+format+"\n", args...)
|
||||
}
|
||||
writef("if src == nil {")
|
||||
writef("\treturn nil")
|
||||
writef("}")
|
||||
writef("dst := new(%s)", name)
|
||||
writef("*dst = *src")
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
fname := t.Field(i).Name()
|
||||
ft := t.Field(i).Type()
|
||||
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
|
||||
continue
|
||||
switch t := typ.Underlying().(type) {
|
||||
case *types.Struct:
|
||||
// We generate two bits of code simultaneously while we walk the struct.
|
||||
// One is the Clone method itself, which we write directly to buf.
|
||||
// The other is a variable assignment that will fail if the struct
|
||||
// changes without the Clone method getting regenerated.
|
||||
// We write that to regenBuf, and then append it to buf at the end.
|
||||
regenBuf := new(bytes.Buffer)
|
||||
writeRegen := func(format string, args ...interface{}) {
|
||||
fmt.Fprintf(regenBuf, format+"\n", args...)
|
||||
}
|
||||
if named, _ := ft.(*types.Named); named != nil {
|
||||
if codegen.IsViewType(ft) {
|
||||
writef("dst.%s = src.%s", fname, fname)
|
||||
writeRegen("// A compilation failure here means this code must be regenerated, with command:")
|
||||
writeRegen("// tailscale.com/cmd/cloner -type %s", *flagTypes)
|
||||
writeRegen("var _%sNeedsRegeneration = %s(struct {", name, name)
|
||||
|
||||
name := typ.Obj().Name()
|
||||
fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name)
|
||||
fmt.Fprintf(buf, "// The result aliases no memory with the original.\n")
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", name, name)
|
||||
writef := func(format string, args ...interface{}) {
|
||||
fmt.Fprintf(buf, "\t"+format+"\n", args...)
|
||||
}
|
||||
writef("if src == nil {")
|
||||
writef("\treturn nil")
|
||||
writef("}")
|
||||
writef("dst := new(%s)", name)
|
||||
writef("*dst = *src")
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
fname := t.Field(i).Name()
|
||||
ft := t.Field(i).Type()
|
||||
|
||||
writeRegen("\t%s %s", fname, importedName(ft))
|
||||
|
||||
if !containsPointers(ft) {
|
||||
continue
|
||||
}
|
||||
if !hasBasicUnderlying(ft) {
|
||||
if named, _ := ft.(*types.Named); named != nil && !hasBasicUnderlying(ft) {
|
||||
writef("dst.%s = *src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch ft := ft.Underlying().(type) {
|
||||
case *types.Slice:
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
n := it.QualifiedName(ft.Elem())
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
|
||||
writef("\tx := *src.%s[i]", fname)
|
||||
writef("\tdst.%s[i] = &x", fname)
|
||||
} else {
|
||||
switch ft := ft.Underlying().(type) {
|
||||
case *types.Slice:
|
||||
if containsPointers(ft.Elem()) {
|
||||
n := importedName(ft.Elem())
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if _, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
|
||||
}
|
||||
case *types.Pointer:
|
||||
if named, _ := ft.Elem().(*types.Named); named != nil && containsPointers(ft.Elem()) {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
n := importedName(ft.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = new(%s)", fname, n)
|
||||
writef("\t*dst.%s = *src.%s", fname, fname)
|
||||
if containsPointers(ft.Elem()) {
|
||||
writef("\t" + `panic("TODO pointers in pointers")`)
|
||||
}
|
||||
writef("}")
|
||||
} else {
|
||||
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
|
||||
}
|
||||
case *types.Pointer:
|
||||
if named, _ := ft.Elem().(*types.Named); named != nil && codegen.ContainsPointers(ft.Elem()) {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
n := it.QualifiedName(ft.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = new(%s)", fname, n)
|
||||
writef("\t*dst.%s = *src.%s", fname, fname)
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
writef("\t" + `panic("TODO pointers in pointers")`)
|
||||
}
|
||||
writef("}")
|
||||
case *types.Map:
|
||||
elem := ft.Elem()
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
if sliceType, isSlice := elem.(*types.Slice); isSlice {
|
||||
n := it.QualifiedName(sliceType.Elem())
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||
writef("\t}")
|
||||
} else if codegen.ContainsPointers(elem) {
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
switch elem.(type) {
|
||||
case *types.Pointer:
|
||||
case *types.Map:
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, importedName(ft.Key()), importedName(ft.Elem()))
|
||||
if sliceType, isSlice := ft.Elem().(*types.Slice); isSlice {
|
||||
n := importedName(sliceType.Elem())
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||
writef("\t}")
|
||||
} else if containsPointers(ft.Elem()) {
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
default:
|
||||
writef("\t\tv2 := v.Clone()")
|
||||
writef("\t\tdst.%s[k] = *v2", fname)
|
||||
writef("\t}")
|
||||
} else {
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
writef("\t\tdst.%s[k] = v", fname)
|
||||
writef("\t}")
|
||||
}
|
||||
writef("\t}")
|
||||
} else {
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
writef("\t\tdst.%s[k] = v", fname)
|
||||
writef("\t}")
|
||||
writef("}")
|
||||
case *types.Struct:
|
||||
writef(`panic("TODO struct %s")`, fname)
|
||||
default:
|
||||
writef(`panic(fmt.Sprintf("TODO: %T", ft))`)
|
||||
}
|
||||
writef("}")
|
||||
default:
|
||||
writef(`panic("TODO: %s (%T)")`, fname, ft)
|
||||
}
|
||||
}
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
|
||||
writeRegen("}{})\n")
|
||||
|
||||
buf.Write(regenBuf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
func hasBasicUnderlying(typ types.Type) bool {
|
||||
switch typ.Underlying().(type) {
|
||||
case *types.Slice, *types.Map:
|
||||
@@ -197,3 +278,34 @@ func hasBasicUnderlying(typ types.Type) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func containsPointers(typ types.Type) bool {
|
||||
switch typ.String() {
|
||||
case "time.Time":
|
||||
// time.Time contains a pointer that does not need copying
|
||||
return false
|
||||
case "inet.af/netaddr.IP":
|
||||
return false
|
||||
}
|
||||
switch ft := typ.Underlying().(type) {
|
||||
case *types.Array:
|
||||
return containsPointers(ft.Elem())
|
||||
case *types.Chan:
|
||||
return true
|
||||
case *types.Interface:
|
||||
return true // a little too broad
|
||||
case *types.Map:
|
||||
return true
|
||||
case *types.Pointer:
|
||||
return true
|
||||
case *types.Slice:
|
||||
return true
|
||||
case *types.Struct:
|
||||
for i := 0; i < ft.NumFields(); i++ {
|
||||
if containsPointers(ft.Field(i).Type()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
// checkSecretPermissions checks the secret access permissions of the current
|
||||
// pod. It returns an error if the basic permissions tailscale needs are
|
||||
// missing, and reports whether the patch permission is additionally present.
|
||||
//
|
||||
// Errors encountered during the access checking process are logged, but ignored
|
||||
// so that the pod tries to fail alive if the permissions exist and there's just
|
||||
// something wrong with SelfSubjectAccessReviews. There shouldn't be, pods
|
||||
// should always be able to use SSARs to assess their own permissions, but since
|
||||
// we didn't use to check permissions this way we'll be cautious in case some
|
||||
// old version of k8s deviates from the current behavior.
|
||||
func checkSecretPermissions(ctx context.Context, secretName string) (canPatch bool, err error) {
|
||||
var errs []error
|
||||
for _, verb := range []string{"get", "update"} {
|
||||
ok, err := checkPermission(ctx, verb, secretName)
|
||||
if err != nil {
|
||||
log.Printf("error checking %s permission on secret %s: %v", verb, secretName, err)
|
||||
} else if !ok {
|
||||
errs = append(errs, fmt.Errorf("missing %s permission on secret %q", verb, secretName))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return false, multierr.New(errs...)
|
||||
}
|
||||
ok, err := checkPermission(ctx, "patch", secretName)
|
||||
if err != nil {
|
||||
log.Printf("error checking patch permission on secret %s: %v", secretName, err)
|
||||
return false, nil
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// checkPermission reports whether the current pod has permission to use the
|
||||
// given verb (e.g. get, update, patch) on secretName.
|
||||
func checkPermission(ctx context.Context, verb, secretName string) (bool, error) {
|
||||
sar := map[string]any{
|
||||
"apiVersion": "authorization.k8s.io/v1",
|
||||
"kind": "SelfSubjectAccessReview",
|
||||
"spec": map[string]any{
|
||||
"resourceAttributes": map[string]any{
|
||||
"namespace": kubeNamespace,
|
||||
"verb": verb,
|
||||
"resource": "secrets",
|
||||
"name": secretName,
|
||||
},
|
||||
},
|
||||
}
|
||||
bs, err := json.Marshal(sar)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp, err := doKubeRequest(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bs, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var res struct {
|
||||
Status struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
} `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(bs, &res); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.Status.Allowed, nil
|
||||
}
|
||||
|
||||
// findKeyInKubeSecret inspects the kube secret secretName for a data
|
||||
// field called "authkey", and returns its value if present.
|
||||
func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := doKubeRequest(ctx, req)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
// Kube secret doesn't exist yet, can't have an authkey.
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bs, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// We use a map[string]any here rather than import corev1.Secret,
|
||||
// because we only do very limited things to the secret, and
|
||||
// importing corev1 adds 12MiB to the compiled binary.
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal(bs, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if d, ok := s["data"].(map[string]any); ok {
|
||||
if v, ok := d["authkey"].(string); ok {
|
||||
bs, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
|
||||
// secret secretName.
|
||||
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string) error {
|
||||
// First check if the secret exists at all. Even if running on
|
||||
// kubernetes, we do not necessarily store state in a k8s secret.
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := doKubeRequest(ctx, req)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode >= 400 && resp.StatusCode <= 499 {
|
||||
// Assume the secret doesn't exist, or we don't have
|
||||
// permission to access it.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
m := map[string]map[string]string{
|
||||
"stringData": {
|
||||
"device_id": string(deviceID),
|
||||
"device_fqdn": fqdn,
|
||||
},
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/strategic-merge-patch+json")
|
||||
if _, err := doKubeRequest(ctx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAuthKey deletes the 'authkey' field of the given kube
|
||||
// secret. No-op if there is no authkey in the secret.
|
||||
func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
|
||||
m := []struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
}{
|
||||
{
|
||||
Op: "remove",
|
||||
Path: "/data/authkey",
|
||||
},
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
if resp, err := doKubeRequest(ctx, req); err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
// This is kubernetes-ese for "the field you asked to
|
||||
// delete already doesn't exist", aka no-op.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
kubeHost string
|
||||
kubeNamespace string
|
||||
kubeToken string
|
||||
kubeHTTP *http.Transport
|
||||
)
|
||||
|
||||
func initKube(root string) {
|
||||
// If running in Kubernetes, set things up so that doKubeRequest
|
||||
// can talk successfully to the kube apiserver.
|
||||
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
|
||||
return
|
||||
}
|
||||
|
||||
kubeHost = os.Getenv("KUBERNETES_SERVICE_HOST") + ":" + os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")
|
||||
|
||||
bs, err := os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/namespace"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading kube namespace: %v", err)
|
||||
}
|
||||
kubeNamespace = strings.TrimSpace(string(bs))
|
||||
|
||||
bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/token"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading kube token: %v", err)
|
||||
}
|
||||
kubeToken = strings.TrimSpace(string(bs))
|
||||
|
||||
bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/ca.crt"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading kube CA cert: %v", err)
|
||||
}
|
||||
cp := x509.NewCertPool()
|
||||
cp.AppendCertsFromPEM(bs)
|
||||
kubeHTTP = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: cp,
|
||||
},
|
||||
IdleConnTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// doKubeRequest sends r to the kube apiserver.
|
||||
func doKubeRequest(ctx context.Context, r *http.Request) (*http.Response, error) {
|
||||
if kubeHTTP == nil {
|
||||
panic("not in kubernetes")
|
||||
}
|
||||
|
||||
r.URL.Scheme = "https"
|
||||
r.URL.Host = kubeHost
|
||||
r.Header.Set("Authorization", "Bearer "+kubeToken)
|
||||
r.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := kubeHTTP.RoundTrip(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return resp, fmt.Errorf("got non-200/201 status code %d", resp.StatusCode)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
// The containerboot binary is a wrapper for starting tailscaled in a container.
|
||||
// It handles reading the desired mode of operation out of environment
|
||||
// variables, bringing up and authenticating Tailscale, and any other
|
||||
// kubernetes-specific side jobs.
|
||||
//
|
||||
// As with most container things, configuration is passed through environment
|
||||
// variables. All configuration is optional.
|
||||
//
|
||||
// - TS_AUTHKEY: the authkey to use for login.
|
||||
// - TS_HOSTNAME: the hostname to request for the node.
|
||||
// - TS_ROUTES: subnet routes to advertise.
|
||||
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
|
||||
// destination.
|
||||
// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'.
|
||||
// - TS_EXTRA_ARGS: extra arguments to 'tailscale up'.
|
||||
// - TS_USERSPACE: run with userspace networking (the default)
|
||||
// instead of kernel networking.
|
||||
// - TS_STATE_DIR: the directory in which to store tailscaled
|
||||
// state. The data should persist across container
|
||||
// restarts.
|
||||
// - TS_ACCEPT_DNS: whether to use the tailnet's DNS configuration.
|
||||
// - TS_KUBE_SECRET: the name of the Kubernetes secret in which to
|
||||
// store tailscaled state.
|
||||
// - TS_SOCKS5_SERVER: the address on which to listen for SOCKS5
|
||||
// proxying into the tailnet.
|
||||
// - TS_OUTBOUND_HTTP_PROXY_LISTEN: the address on which to listen
|
||||
// for HTTP proxying into the tailnet.
|
||||
// - TS_SOCKET: the path where the tailscaled LocalAPI socket should
|
||||
// be created.
|
||||
// - TS_AUTH_ONCE: if true, only attempt to log in if not already
|
||||
// logged in. If false (the default, for backwards
|
||||
// compatibility), forcibly log in every time the
|
||||
// container starts.
|
||||
//
|
||||
// When running on Kubernetes, containerboot defaults to storing state in the
|
||||
// "tailscale" kube secret. To store state on local disk instead, set
|
||||
// TS_KUBE_SECRET="" and TS_STATE_DIR=/path/to/storage/dir. The state dir should
|
||||
// be persistent storage.
|
||||
//
|
||||
// Additionally, if TS_AUTHKEY is not set and the TS_KUBE_SECRET contains an
|
||||
// "authkey" field, that key is used as the tailscale authkey.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/util/deephash"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetPrefix("boot: ")
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnv("TS_ROUTES", ""),
|
||||
ProxyTo: defaultEnv("TS_DEST_IP", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
}
|
||||
|
||||
if cfg.ProxyTo != "" && cfg.UserspaceMode {
|
||||
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
|
||||
if !cfg.UserspaceMode {
|
||||
if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("Unable to create tuntap device file: %v", err)
|
||||
}
|
||||
if cfg.ProxyTo != "" || cfg.Routes != "" {
|
||||
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.Routes); err != nil {
|
||||
log.Printf("Failed to enable IP forwarding: %v", err)
|
||||
log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.")
|
||||
if cfg.InKubernetes {
|
||||
log.Fatalf("You can either set the sysctls as a privileged initContainer, or run the tailscale container with privileged=true.")
|
||||
} else {
|
||||
log.Fatalf("You can fix this by running the container with privileged=true, or the equivalent in your container runtime that permits access to sysctls.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.InKubernetes {
|
||||
initKube(cfg.Root)
|
||||
}
|
||||
|
||||
// Context is used for all setup stuff until we're in steady
|
||||
// state, so that if something is hanging we eventually time out
|
||||
// and crashloop the container.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" {
|
||||
canPatch, err := checkSecretPermissions(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
|
||||
if cfg.AuthKey == "" {
|
||||
key, err := findKeyInKubeSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Getting authkey from kube secret: %v", err)
|
||||
}
|
||||
if key != "" {
|
||||
// This behavior of pulling authkeys from kube secrets was added
|
||||
// at the same time as the patch permission, so we can enforce
|
||||
// that we must be able to patch out the authkey after
|
||||
// authenticating if you want to use this feature. This avoids
|
||||
// us having to deal with the case where we might leave behind
|
||||
// an unnecessary reusable authkey in a secret, like a rake in
|
||||
// the grass.
|
||||
if !cfg.KubernetesCanPatch {
|
||||
log.Fatalf("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
|
||||
}
|
||||
log.Print("Using authkey found in kube secret")
|
||||
cfg.AuthKey = key
|
||||
} else {
|
||||
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client, daemonPid, err := startTailscaled(ctx, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to bring up tailscale: %v", err)
|
||||
}
|
||||
|
||||
w, err := client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to watch tailscaled for updates: %v", err)
|
||||
}
|
||||
|
||||
// Because we're still shelling out to `tailscale up` to get access to its
|
||||
// flag parser, we have to stop watching the IPN bus so that we can block on
|
||||
// the subcommand without stalling anything. Then once it's done, we resume
|
||||
// watching the bus.
|
||||
//
|
||||
// Depending on the requested mode of operation, this auth step happens at
|
||||
// different points in containerboot's lifecycle, hence the helper function.
|
||||
didLogin := false
|
||||
authTailscale := func() error {
|
||||
if didLogin {
|
||||
return nil
|
||||
}
|
||||
didLogin = true
|
||||
w.Close()
|
||||
if err := tailscaleUp(ctx, cfg); err != nil {
|
||||
return fmt.Errorf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewatching tailscaled for updates after auth: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !cfg.AuthOnce {
|
||||
if err := authTailscale(); err != nil {
|
||||
log.Fatalf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
authLoop:
|
||||
for {
|
||||
n, err := w.Next()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read from tailscaled: %v", err)
|
||||
}
|
||||
|
||||
if n.State != nil {
|
||||
switch *n.State {
|
||||
case ipn.NeedsLogin:
|
||||
if err := authTailscale(); err != nil {
|
||||
log.Fatalf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
case ipn.NeedsMachineAuth:
|
||||
log.Printf("machine authorization required, please visit the admin panel")
|
||||
case ipn.Running:
|
||||
// Technically, all we want is to keep monitoring the bus for
|
||||
// netmap updates. However, in order to make the container crash
|
||||
// if tailscale doesn't initially come up, the watch has a
|
||||
// startup deadline on it. So, we have to break out of this
|
||||
// watch loop, cancel the watch, and watch again with no
|
||||
// deadline to continue monitoring for changes.
|
||||
break authLoop
|
||||
default:
|
||||
log.Printf("tailscaled in state %q, waiting", *n.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Close()
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && cfg.AuthOnce {
|
||||
// We were told to only auth once, so any secret-bound
|
||||
// authkey is no longer needed. We don't strictly need to
|
||||
// wipe it, but it's good hygiene.
|
||||
log.Printf("Deleting authkey from kube secret")
|
||||
if err := deleteAuthKey(ctx, cfg.KubeSecret); err != nil {
|
||||
log.Fatalf("deleting authkey from kube secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err = client.WatchIPNBus(context.Background(), ipn.NotifyInitialNetMap|ipn.NotifyInitialState)
|
||||
if err != nil {
|
||||
log.Fatalf("rewatching tailscaled for updates after auth: %v", err)
|
||||
}
|
||||
|
||||
var (
|
||||
wantProxy = cfg.ProxyTo != ""
|
||||
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
|
||||
startupTasksDone = false
|
||||
currentIPs deephash.Sum // tailscale IPs assigned to device
|
||||
currentDeviceInfo deephash.Sum // device ID and fqdn
|
||||
)
|
||||
for {
|
||||
n, err := w.Next()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read from tailscaled: %v", err)
|
||||
}
|
||||
|
||||
if n.State != nil && *n.State != ipn.Running {
|
||||
// Something's gone wrong and we've left the authenticated state.
|
||||
// Our container image never recovered gracefully from this, and the
|
||||
// control flow required to make it work now is hard. So, just crash
|
||||
// the container and rely on the container runtime to restart us,
|
||||
// whereupon we'll go through initial auth again.
|
||||
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
|
||||
}
|
||||
if n.NetMap != nil {
|
||||
if cfg.ProxyTo != "" && len(n.NetMap.Addresses) > 0 && deephash.Update(¤tIPs, &n.NetMap.Addresses) {
|
||||
if err := installIPTablesRule(ctx, cfg.ProxyTo, n.NetMap.Addresses); err != nil {
|
||||
log.Fatalf("installing proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
deviceInfo := []any{n.NetMap.SelfNode.StableID, n.NetMap.SelfNode.Name}
|
||||
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(¤tDeviceInfo, &deviceInfo) {
|
||||
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID, n.NetMap.SelfNode.Name); err != nil {
|
||||
log.Fatalf("storing device ID in kube secret: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !startupTasksDone {
|
||||
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
|
||||
// This log message is used in tests to detect when all
|
||||
// post-auth configuration is done.
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
startupTasksDone = true
|
||||
|
||||
// Reap all processes, since we are PID1 and need to collect zombies. We can
|
||||
// only start doing this once we've stopped shelling out to things
|
||||
// `tailscale up`, otherwise this goroutine can reap the CLI subprocesses
|
||||
// and wedge bringup.
|
||||
go func() {
|
||||
for {
|
||||
var status unix.WaitStatus
|
||||
pid, err := unix.Wait4(-1, &status, 0, nil)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("Waiting for exited processes: %v", err)
|
||||
}
|
||||
if pid == daemonPid {
|
||||
log.Printf("Tailscaled exited")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, int, error) {
|
||||
args := tailscaledArgs(cfg)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, unix.SIGTERM, unix.SIGINT)
|
||||
// tailscaled runs without context, since it needs to persist
|
||||
// beyond the startup timeout in ctx.
|
||||
cmd := exec.Command("tailscaled", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
log.Printf("Starting tailscaled")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, 0, fmt.Errorf("starting tailscaled failed: %v", err)
|
||||
}
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Printf("Received SIGTERM from container runtime, shutting down tailscaled")
|
||||
cmd.Process.Signal(unix.SIGTERM)
|
||||
}()
|
||||
|
||||
// Wait for the socket file to appear, otherwise API ops will racily fail.
|
||||
log.Printf("Waiting for tailscaled socket")
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
log.Fatalf("Timed out waiting for tailscaled socket")
|
||||
}
|
||||
_, err := os.Stat(cfg.Socket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
} else if err != nil {
|
||||
log.Fatalf("Waiting for tailscaled socket: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
tsClient := &tailscale.LocalClient{
|
||||
Socket: cfg.Socket,
|
||||
UseSocketOnly: true,
|
||||
}
|
||||
|
||||
return tsClient, cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
// tailscaledArgs uses cfg to construct the argv for tailscaled.
|
||||
func tailscaledArgs(cfg *settings) []string {
|
||||
args := []string{"--socket=" + cfg.Socket}
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret)
|
||||
if cfg.StateDir == "" {
|
||||
cfg.StateDir = "/tmp"
|
||||
}
|
||||
fallthrough
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--statedir="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
}
|
||||
|
||||
if cfg.UserspaceMode {
|
||||
args = append(args, "--tun=userspace-networking")
|
||||
} else if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
|
||||
}
|
||||
|
||||
if cfg.SOCKSProxyAddr != "" {
|
||||
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
|
||||
}
|
||||
if cfg.HTTPProxyAddr != "" {
|
||||
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
|
||||
}
|
||||
if cfg.DaemonExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// tailscaleUp uses cfg to run 'tailscale up'.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
if cfg.Routes != "" {
|
||||
args = append(args, "--advertise-routes="+cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
if cfg.ExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.ExtraArgs)...)
|
||||
}
|
||||
log.Printf("Running 'tailscale up'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTunFile checks that /dev/net/tun exists, creating it if
|
||||
// missing.
|
||||
func ensureTunFile(root string) error {
|
||||
// Verify that /dev/net/tun exists, in some container envs it
|
||||
// needs to be mknod-ed.
|
||||
if _, err := os.Stat(filepath.Join(root, "dev/net")); errors.Is(err, fs.ErrNotExist) {
|
||||
if err := os.MkdirAll(filepath.Join(root, "dev/net"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "dev/net/tun")); errors.Is(err, fs.ErrNotExist) {
|
||||
dev := unix.Mkdev(10, 200) // tuntap major and minor
|
||||
if err := unix.Mknod(filepath.Join(root, "dev/net/tun"), 0600|unix.S_IFCHR, int(dev)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, proxyTo, routes string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
if proxyTo != "" {
|
||||
proxyIP, err := netip.ParseAddr(proxyTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
if routes != "" {
|
||||
for _, route := range strings.Split(routes, ",") {
|
||||
cidr, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid subnet route: %v", err)
|
||||
}
|
||||
if cidr.Addr().Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paths []string
|
||||
if v4Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
|
||||
}
|
||||
if v6Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
|
||||
}
|
||||
|
||||
// In some common configurations (e.g. default docker,
|
||||
// kubernetes), the container environment denies write access to
|
||||
// most sysctls, including IP forwarding controls. Check the
|
||||
// sysctl values before trying to change them, so that we
|
||||
// gracefully do nothing if the container's already been set up
|
||||
// properly by e.g. a k8s initContainer.
|
||||
for _, path := range paths {
|
||||
bs, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q: %w", path, err)
|
||||
}
|
||||
if v := strings.TrimSpace(string(bs)); v != "1" {
|
||||
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("enabling %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIPTablesRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv0 := "iptables"
|
||||
if dst.Is6() {
|
||||
argv0 = "ip6tables"
|
||||
}
|
||||
var local string
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr().String()
|
||||
break
|
||||
}
|
||||
if local == "" {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
// Technically, if the control server ever changes the IPs assigned to this
|
||||
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
|
||||
// for now we'll live with it.
|
||||
cmd := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "-d", local, "-j", "DNAT", "--to-destination", dstStr)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("executing iptables failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Hostname string
|
||||
Routes string
|
||||
ProxyTo string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
// unset.
|
||||
func defaultEnv(name, defVal string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
func defaultEnvs(names []string, defVal string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultBool returns the boolean value of the given envvar name, or
|
||||
// defVal if unset or not a bool.
|
||||
func defaultBool(name string, defVal bool) bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defVal
|
||||
}
|
||||
return ret
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This is a fake tailscale CLI (and also iptables and ip6tables) that
|
||||
# records its arguments and exits successfully.
|
||||
#
|
||||
# It is used by main_test.go to test the behavior of containerboot.
|
||||
|
||||
echo $0 $@ >>$TS_TEST_RECORD_ARGS
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This is a fake tailscale CLI that records its arguments, symlinks a
|
||||
# fake LocalAPI socket into place, and does nothing until terminated.
|
||||
#
|
||||
# It is used by main_test.go to test the behavior of containerboot.
|
||||
|
||||
set -eu
|
||||
|
||||
echo $0 $@ >>$TS_TEST_RECORD_ARGS
|
||||
|
||||
socket=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--socket=*)
|
||||
socket="${1#--socket=}"
|
||||
shift
|
||||
;;
|
||||
--socket)
|
||||
shift
|
||||
socket="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$socket" ]]; then
|
||||
echo "didn't find socket path in args"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -s "$TS_TEST_SOCKET" "$socket"
|
||||
|
||||
while true; do sleep 1; done
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
@@ -11,36 +12,23 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/syncs"
|
||||
)
|
||||
|
||||
const refreshTimeout = time.Minute
|
||||
|
||||
type dnsEntryMap map[string][]net.IP
|
||||
|
||||
var (
|
||||
dnsCache syncs.AtomicValue[dnsEntryMap]
|
||||
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
|
||||
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
|
||||
)
|
||||
|
||||
var (
|
||||
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
|
||||
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
|
||||
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
|
||||
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
|
||||
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
|
||||
dnsMu sync.Mutex
|
||||
dnsCache = map[string][]net.IP{}
|
||||
)
|
||||
|
||||
var bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
|
||||
|
||||
func refreshBootstrapDNSLoop() {
|
||||
if *bootstrapDNS == "" && *unpublishedDNS == "" {
|
||||
if *bootstrapDNS == "" {
|
||||
return
|
||||
}
|
||||
for {
|
||||
refreshBootstrapDNS()
|
||||
refreshUnpublishedDNS()
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}
|
||||
@@ -49,34 +37,9 @@ func refreshBootstrapDNS() {
|
||||
if *bootstrapDNS == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
|
||||
j, err := json.MarshalIndent(dnsEntries, "", "\t")
|
||||
if err != nil {
|
||||
// leave the old values in place
|
||||
return
|
||||
}
|
||||
|
||||
dnsCache.Store(dnsEntries)
|
||||
dnsCacheBytes.Store(j)
|
||||
}
|
||||
|
||||
func refreshUnpublishedDNS() {
|
||||
if *unpublishedDNS == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
||||
defer cancel()
|
||||
|
||||
dnsEntries := resolveList(ctx, strings.Split(*unpublishedDNS, ","))
|
||||
unpublishedDNSCache.Store(dnsEntries)
|
||||
}
|
||||
|
||||
func resolveList(ctx context.Context, names []string) dnsEntryMap {
|
||||
dnsEntries := make(dnsEntryMap)
|
||||
|
||||
names := strings.Split(*bootstrapDNS, ",")
|
||||
var r net.Resolver
|
||||
for _, name := range names {
|
||||
addrs, err := r.LookupIP(ctx, "ip", name)
|
||||
@@ -84,49 +47,23 @@ func resolveList(ctx context.Context, names []string) dnsEntryMap {
|
||||
log.Printf("bootstrap DNS lookup %q: %v", name, err)
|
||||
continue
|
||||
}
|
||||
dnsEntries[name] = addrs
|
||||
dnsMu.Lock()
|
||||
dnsCache[name] = addrs
|
||||
dnsMu.Unlock()
|
||||
}
|
||||
return dnsEntries
|
||||
}
|
||||
|
||||
func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
bootstrapDNSRequests.Add(1)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Bootstrap DNS requests occur cross-regions, and are randomized per
|
||||
// request, so keeping a connection open is pointlessly expensive.
|
||||
w.Header().Set("Connection", "close")
|
||||
|
||||
// Try answering a query from our hidden map first
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
|
||||
unpublishedDNSHits.Add(1)
|
||||
|
||||
// Only return the specific query, not everything.
|
||||
m := dnsEntryMap{q: ips}
|
||||
j, err := json.MarshalIndent(m, "", "\t")
|
||||
if err == nil {
|
||||
w.Write(j)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a "q" query for a name in the published cache
|
||||
// list, then track whether that's a hit/miss.
|
||||
if m, ok := dnsCache.Load()[q]; ok {
|
||||
if len(m) > 0 {
|
||||
publishedDNSHits.Add(1)
|
||||
} else {
|
||||
publishedDNSMisses.Add(1)
|
||||
}
|
||||
} else {
|
||||
// If it wasn't in either cache, treat this as a query
|
||||
// for the unpublished cache, and thus a cache miss.
|
||||
unpublishedDNSMisses.Add(1)
|
||||
}
|
||||
dnsMu.Lock()
|
||||
j, err := json.MarshalIndent(dnsCache, "", "\t")
|
||||
dnsMu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("bootstrap DNS JSON: %v", err)
|
||||
http.Error(w, "JSON marshal error", 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to returning the public set of cached DNS names
|
||||
j := dnsCacheBytes.Load()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(j)
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkHandleBootstrapDNS(b *testing.B) {
|
||||
prev := *bootstrapDNS
|
||||
*bootstrapDNS = "log.tailscale.io,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com"
|
||||
defer func() {
|
||||
*bootstrapDNS = prev
|
||||
}()
|
||||
refreshBootstrapDNS()
|
||||
w := new(bitbucketResponseWriter)
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape("log.tailscale.io"), nil)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(b *testing.PB) {
|
||||
for b.Next() {
|
||||
handleBootstrapDNS(w, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type bitbucketResponseWriter struct{}
|
||||
|
||||
func (b *bitbucketResponseWriter) Header() http.Header { return make(http.Header) }
|
||||
|
||||
func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
|
||||
|
||||
func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleBootstrapDNS(w, req)
|
||||
|
||||
res := w.Result()
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("got status=%d; want %d", res.StatusCode, 200)
|
||||
}
|
||||
var ips dnsEntryMap
|
||||
if err := json.NewDecoder(res.Body).Decode(&ips); err != nil {
|
||||
t.Fatalf("error decoding response body: %v", err)
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func TestUnpublishedDNS(t *testing.T) {
|
||||
const published = "login.tailscale.com"
|
||||
const unpublished = "log.tailscale.io"
|
||||
|
||||
prev1, prev2 := *bootstrapDNS, *unpublishedDNS
|
||||
*bootstrapDNS = published
|
||||
*unpublishedDNS = unpublished
|
||||
t.Cleanup(func() {
|
||||
*bootstrapDNS = prev1
|
||||
*unpublishedDNS = prev2
|
||||
})
|
||||
|
||||
refreshBootstrapDNS()
|
||||
refreshUnpublishedDNS()
|
||||
|
||||
hasResponse := func(q string) bool {
|
||||
_, found := getBootstrapDNS(t, q)[q]
|
||||
return found
|
||||
}
|
||||
|
||||
if !hasResponse(published) {
|
||||
t.Errorf("expected response for: %s", published)
|
||||
}
|
||||
if !hasResponse(unpublished) {
|
||||
t.Errorf("expected response for: %s", unpublished)
|
||||
}
|
||||
|
||||
// Verify that querying for a random query or a real query does not
|
||||
// leak our unpublished domain
|
||||
m1 := getBootstrapDNS(t, published)
|
||||
if _, found := m1[unpublished]; found {
|
||||
t.Errorf("found unpublished domain %s: %+v", unpublished, m1)
|
||||
}
|
||||
m2 := getBootstrapDNS(t, "random.example.com")
|
||||
if _, found := m2[unpublished]; found {
|
||||
t.Errorf("found unpublished domain %s: %+v", unpublished, m2)
|
||||
}
|
||||
}
|
||||
|
||||
func resetMetrics() {
|
||||
publishedDNSHits.Set(0)
|
||||
publishedDNSMisses.Set(0)
|
||||
unpublishedDNSHits.Set(0)
|
||||
unpublishedDNSMisses.Set(0)
|
||||
}
|
||||
|
||||
// Verify that we don't count an empty list in the unpublishedDNSCache as a
|
||||
// cache hit in our metrics.
|
||||
func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
pub := dnsEntryMap{
|
||||
"tailscale.com": {net.IPv4(10, 10, 10, 10)},
|
||||
}
|
||||
dnsCache.Store(pub)
|
||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
||||
|
||||
unpublishedDNSCache.Store(dnsEntryMap{
|
||||
"log.tailscale.io": {},
|
||||
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
|
||||
})
|
||||
|
||||
t.Run("CacheMiss", func(t *testing.T) {
|
||||
// One domain in map but empty, one not in map at all
|
||||
for _, q := range []string{"log.tailscale.io", "login.tailscale.com"} {
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, q)
|
||||
|
||||
// Expected our public map to be returned on a cache miss
|
||||
if !reflect.DeepEqual(ips, pub) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, pub)
|
||||
}
|
||||
if v := unpublishedDNSHits.Value(); v != 0 {
|
||||
t.Errorf("got hits=%d; want 0", v)
|
||||
}
|
||||
if v := unpublishedDNSMisses.Value(); v != 1 {
|
||||
t.Errorf("got misses=%d; want 1", v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Verify that we do get a valid response and metric.
|
||||
t.Run("CacheHit", func(t *testing.T) {
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||
want := dnsEntryMap{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
if !reflect.DeepEqual(ips, want) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, want)
|
||||
}
|
||||
if v := unpublishedDNSHits.Value(); v != 1 {
|
||||
t.Errorf("got hits=%d; want 1", v)
|
||||
}
|
||||
if v := unpublishedDNSMisses.Value(); v != 0 {
|
||||
t.Errorf("got misses=%d; want 0", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`)
|
||||
|
||||
type certProvider interface {
|
||||
// TLSConfig creates a new TLS config suitable for net/http.Server servers.
|
||||
//
|
||||
// The returned Config must have a GetCertificate function set and that
|
||||
// function must return a unique *tls.Certificate for each call. The
|
||||
// returned *tls.Certificate will be mutated by the caller to append to the
|
||||
// (*tls.Certificate).Certificate field.
|
||||
TLSConfig() *tls.Config
|
||||
// HTTPHandler handle ACME related request, if any.
|
||||
HTTPHandler(fallback http.Handler) http.Handler
|
||||
}
|
||||
|
||||
func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("missing required --certdir flag")
|
||||
}
|
||||
switch mode {
|
||||
case "letsencrypt":
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(hostname),
|
||||
Cache: autocert.DirCache(dir),
|
||||
}
|
||||
if hostname == "derp.tailscale.com" {
|
||||
certManager.HostPolicy = prodAutocertHostPolicy
|
||||
certManager.Email = "security@tailscale.com"
|
||||
}
|
||||
return certManager, nil
|
||||
case "manual":
|
||||
return NewManualCertManager(dir, hostname)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupport cert mode: %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
type manualCertManager struct {
|
||||
cert *tls.Certificate
|
||||
hostname string
|
||||
}
|
||||
|
||||
// NewManualCertManager returns a cert provider which read certificate by given hostname on create.
|
||||
func NewManualCertManager(certdir, hostname string) (certProvider, error) {
|
||||
keyname := unsafeHostnameCharacters.ReplaceAllString(hostname, "")
|
||||
crtPath := filepath.Join(certdir, keyname+".crt")
|
||||
keyPath := filepath.Join(certdir, keyname+".key")
|
||||
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err)
|
||||
}
|
||||
// ensure hostname matches with the certificate
|
||||
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can not load cert: %w", err)
|
||||
}
|
||||
if err := x509Cert.VerifyHostname(hostname); err != nil {
|
||||
return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err)
|
||||
}
|
||||
return &manualCertManager{cert: &cert, hostname: hostname}, nil
|
||||
}
|
||||
|
||||
func (m *manualCertManager) TLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
Certificates: nil,
|
||||
NextProtos: []string{
|
||||
"h2", "http/1.1", // enable HTTP/2
|
||||
},
|
||||
GetCertificate: m.getCertificate,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi.ServerName != m.hostname {
|
||||
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
|
||||
}
|
||||
|
||||
// Return a shallow copy of the cert so the caller can append to its
|
||||
// Certificate field.
|
||||
certCopy := new(tls.Certificate)
|
||||
*certCopy = *m.cert
|
||||
certCopy.Certificate = certCopy.Certificate[:len(certCopy.Certificate):len(certCopy.Certificate)]
|
||||
return certCopy, nil
|
||||
}
|
||||
|
||||
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
|
||||
return fallback
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depaware)
|
||||
|
||||
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
|
||||
filippo.io/edwards25519/field from filippo.io/edwards25519
|
||||
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
|
||||
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
|
||||
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
|
||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
|
||||
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
L github.com/josharian/native from github.com/mdlayher/netlink+
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces
|
||||
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
|
||||
github.com/klauspost/compress/flate from nhooyr.io/websocket
|
||||
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
|
||||
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
|
||||
github.com/x448/float16 from github.com/fxamacker/cbor/v2
|
||||
💣 go4.org/mem from tailscale.com/client/tailscale+
|
||||
go4.org/netipx from tailscale.com/wgengine/filter
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
|
||||
nhooyr.io/websocket from tailscale.com/cmd/derper+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/atomicfile from tailscale.com/cmd/derper+
|
||||
tailscale.com/client/tailscale from tailscale.com/derp
|
||||
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale
|
||||
tailscale.com/derp from tailscale.com/cmd/derper+
|
||||
tailscale.com/derp/derphttp from tailscale.com/cmd/derper
|
||||
tailscale.com/disco from tailscale.com/derp
|
||||
tailscale.com/envknob from tailscale.com/derp+
|
||||
tailscale.com/health from tailscale.com/net/tlsdial
|
||||
tailscale.com/hostinfo from tailscale.com/net/interfaces+
|
||||
tailscale.com/ipn from tailscale.com/client/tailscale
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/metrics from tailscale.com/cmd/derper+
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
💣 tailscale.com/net/interfaces from tailscale.com/net/netns+
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/filter
|
||||
tailscale.com/net/stun from tailscale.com/cmd/derper
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
|
||||
tailscale.com/paths from tailscale.com/client/tailscale
|
||||
tailscale.com/safesocket from tailscale.com/client/tailscale
|
||||
tailscale.com/syncs from tailscale.com/cmd/derper+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||
tailscale.com/tsweb from tailscale.com/cmd/derper
|
||||
tailscale.com/types/dnstype from tailscale.com/tailcfg
|
||||
tailscale.com/types/empty from tailscale.com/ipn
|
||||
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
|
||||
tailscale.com/types/key from tailscale.com/cmd/derper+
|
||||
tailscale.com/types/lazy from tailscale.com/version+
|
||||
tailscale.com/types/logger from tailscale.com/cmd/derper+
|
||||
tailscale.com/types/netmap from tailscale.com/ipn
|
||||
tailscale.com/types/opt from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/persist from tailscale.com/ipn
|
||||
tailscale.com/types/preftype from tailscale.com/ipn
|
||||
tailscale.com/types/ptr from tailscale.com/hostinfo+
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
tailscale.com/types/tkatype from tailscale.com/types/key+
|
||||
tailscale.com/types/views from tailscale.com/ipn/ipnstate+
|
||||
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
tailscale.com/util/mak from tailscale.com/syncs+
|
||||
tailscale.com/util/multierr from tailscale.com/health
|
||||
tailscale.com/util/set from tailscale.com/health
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/vizerror from tailscale.com/tsweb
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/hostinfo+
|
||||
tailscale.com/wgengine/filter from tailscale.com/types/netmap
|
||||
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
|
||||
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/blake2s from tailscale.com/tka
|
||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/hkdf from crypto/tls
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/exp/constraints from golang.org/x/exp/slices
|
||||
golang.org/x/exp/slices from tailscale.com/net/tsaddr+
|
||||
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
golang.org/x/net/http/httpguts from net/http
|
||||
golang.org/x/net/http/httpproxy from net/http
|
||||
golang.org/x/net/http2/hpack from net/http
|
||||
golang.org/x/net/idna from golang.org/x/crypto/acme/autocert+
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from net+
|
||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
|
||||
LD golang.org/x/sys/unix from github.com/jsimonetti/rtnetlink/internal/unix+
|
||||
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+
|
||||
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
|
||||
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
|
||||
W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
|
||||
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
|
||||
golang.org/x/text/unicode/norm from golang.org/x/net/idna
|
||||
golang.org/x/time/rate from tailscale.com/cmd/derper+
|
||||
bufio from compress/flate+
|
||||
bytes from bufio+
|
||||
compress/flate from compress/gzip+
|
||||
compress/gzip from internal/profile+
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdsa+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509
|
||||
crypto/ecdh from crypto/ecdsa+
|
||||
crypto/ecdsa from crypto/tls+
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/tls from golang.org/x/crypto/acme+
|
||||
crypto/x509 from crypto/tls+
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
embed from crypto/internal/nistec+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from tailscale.com/tka
|
||||
encoding/base64 from encoding/json+
|
||||
encoding/binary from compress/gzip+
|
||||
encoding/hex from crypto/x509+
|
||||
encoding/json from expvar+
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
expvar from tailscale.com/cmd/derper+
|
||||
flag from tailscale.com/cmd/derper
|
||||
fmt from compress/flate+
|
||||
hash from crypto+
|
||||
hash/crc32 from compress/gzip+
|
||||
hash/maphash from go4.org/mem
|
||||
html from net/http/pprof+
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
io/ioutil from github.com/mitchellh/go-ps+
|
||||
log from expvar+
|
||||
math from compress/flate+
|
||||
math/big from crypto/dsa+
|
||||
math/bits from compress/flate+
|
||||
math/rand from github.com/mdlayher/netlink+
|
||||
mime from mime/multipart+
|
||||
mime/multipart from net/http
|
||||
mime/quotedprintable from mime/multipart
|
||||
net from crypto/tls+
|
||||
net/http from expvar+
|
||||
net/http/httptrace from net/http+
|
||||
net/http/internal from net/http
|
||||
net/http/pprof from tailscale.com/tsweb
|
||||
net/netip from go4.org/netipx+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os/exec from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
|
||||
W os/user from tailscale.com/util/winutil
|
||||
path from golang.org/x/crypto/acme/autocert+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
regexp from internal/profile+
|
||||
regexp/syntax from regexp
|
||||
runtime/debug from golang.org/x/crypto/acme+
|
||||
runtime/pprof from net/http/pprof
|
||||
runtime/trace from net/http/pprof
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
strings from bufio+
|
||||
sync from compress/flate+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
text/tabwriter from runtime/pprof
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
unicode/utf16 from crypto/x509+
|
||||
unicode/utf8 from bufio+
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The derper binary is a simple DERP server.
|
||||
package main // import "tailscale.com/cmd/derper"
|
||||
@@ -11,83 +12,50 @@ import (
|
||||
"errors"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"golang.org/x/time/rate"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/logpolicy"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/wgkey"
|
||||
)
|
||||
|
||||
var (
|
||||
dev = flag.Bool("dev", false, "run in localhost development mode")
|
||||
addr = flag.String("a", ":443", "server HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces.")
|
||||
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
|
||||
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
|
||||
configPath = flag.String("c", "", "config file path")
|
||||
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
|
||||
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
|
||||
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
|
||||
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
|
||||
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
|
||||
|
||||
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
|
||||
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
|
||||
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
|
||||
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
|
||||
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
|
||||
|
||||
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
|
||||
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
|
||||
dev = flag.Bool("dev", false, "run in localhost development mode")
|
||||
addr = flag.String("a", ":443", "server address")
|
||||
configPath = flag.String("c", "", "config file path")
|
||||
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
|
||||
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
|
||||
logCollection = flag.String("logcollection", "", "If non-empty, logtail collection to log to")
|
||||
runSTUN = flag.Bool("stun", false, "also run a STUN server")
|
||||
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
|
||||
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
|
||||
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
|
||||
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
|
||||
)
|
||||
|
||||
var (
|
||||
stats = new(metrics.Set)
|
||||
stunDisposition = &metrics.LabelMap{Label: "disposition"}
|
||||
stunAddrFamily = &metrics.LabelMap{Label: "family"}
|
||||
tlsRequestVersion = &metrics.LabelMap{Label: "version"}
|
||||
tlsActiveVersion = &metrics.LabelMap{Label: "version"}
|
||||
|
||||
stunReadError = stunDisposition.Get("read_error")
|
||||
stunNotSTUN = stunDisposition.Get("not_stun")
|
||||
stunWriteError = stunDisposition.Get("write_error")
|
||||
stunSuccess = stunDisposition.Get("success")
|
||||
|
||||
stunIPv4 = stunAddrFamily.Get("ipv4")
|
||||
stunIPv6 = stunAddrFamily.Get("ipv6")
|
||||
)
|
||||
|
||||
func init() {
|
||||
stats.Set("counter_requests", stunDisposition)
|
||||
stats.Set("counter_addrfamily", stunAddrFamily)
|
||||
expvar.Publish("stun", stats)
|
||||
expvar.Publish("derper_tls_request_version", tlsRequestVersion)
|
||||
expvar.Publish("gauge_derper_tls_active_version", tlsActiveVersion)
|
||||
}
|
||||
|
||||
type config struct {
|
||||
PrivateKey key.NodePrivate
|
||||
PrivateKey wgkey.Private
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
if *dev {
|
||||
return config{PrivateKey: key.NewNode()}
|
||||
return config{PrivateKey: mustNewKey()}
|
||||
}
|
||||
if *configPath == "" {
|
||||
if os.Getuid() == 0 {
|
||||
@@ -97,7 +65,7 @@ func loadConfig() config {
|
||||
}
|
||||
log.Printf("no config path specified; using %s", *configPath)
|
||||
}
|
||||
b, err := os.ReadFile(*configPath)
|
||||
b, err := ioutil.ReadFile(*configPath)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return writeNewConfig()
|
||||
@@ -113,13 +81,21 @@ func loadConfig() config {
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewKey() wgkey.Private {
|
||||
key, err := wgkey.NewPrivate()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func writeNewConfig() config {
|
||||
k := key.NewNode()
|
||||
key := mustNewKey()
|
||||
if err := os.MkdirAll(filepath.Dir(*configPath), 0777); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cfg := config{
|
||||
PrivateKey: k,
|
||||
PrivateKey: key,
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
@@ -135,25 +111,27 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *dev {
|
||||
*logCollection = ""
|
||||
*addr = ":3340" // above the keys DERP
|
||||
log.Printf("Running in dev mode.")
|
||||
tsweb.DevMode = true
|
||||
}
|
||||
|
||||
listenHost, _, err := net.SplitHostPort(*addr)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid server address: %v", err)
|
||||
var logPol *logpolicy.Policy
|
||||
if *logCollection != "" {
|
||||
logPol = logpolicy.New(*logCollection)
|
||||
log.SetOutput(logPol.Logtail)
|
||||
}
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
serveTLS := tsweb.IsProd443(*addr) || *certMode == "manual"
|
||||
letsEncrypt := tsweb.IsProd443(*addr)
|
||||
|
||||
s := derp.NewServer(cfg.PrivateKey, log.Printf)
|
||||
s := derp.NewServer(key.Private(cfg.PrivateKey), log.Printf)
|
||||
s.SetVerifyClient(*verifyClients)
|
||||
|
||||
if *meshPSKFile != "" {
|
||||
b, err := os.ReadFile(*meshPSKFile)
|
||||
b, err := ioutil.ReadFile(*meshPSKFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -170,16 +148,7 @@ func main() {
|
||||
expvar.Publish("derp", s.ExpVar())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
if *runDERP {
|
||||
derpHandler := derphttp.Handler(s)
|
||||
derpHandler = addWebSocketSupport(s, derpHandler)
|
||||
mux.Handle("/derp", derpHandler)
|
||||
} else {
|
||||
mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "derp server disabled", http.StatusNotFound)
|
||||
}))
|
||||
}
|
||||
mux.HandleFunc("/derp/probe", probeHandler)
|
||||
mux.Handle("/derp", derphttp.Handler(s))
|
||||
go refreshBootstrapDNSLoop()
|
||||
mux.HandleFunc("/bootstrap-dns", handleBootstrapDNS)
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -194,17 +163,10 @@ func main() {
|
||||
server.
|
||||
</p>
|
||||
`)
|
||||
if !*runDERP {
|
||||
io.WriteString(w, `<p>Status: <b>disabled</b></p>`)
|
||||
}
|
||||
if tsweb.AllowDebugAccess(r) {
|
||||
io.WriteString(w, "<p>Debug info at <a href='/debug/'>/debug/</a>.</p>\n")
|
||||
}
|
||||
}))
|
||||
mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "User-agent: *\nDisallow: /\n")
|
||||
}))
|
||||
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
|
||||
debug := tsweb.Debugger(mux)
|
||||
debug.KV("TLS hostname", *hostname)
|
||||
debug.KV("Mesh key", s.HasMeshKey())
|
||||
@@ -219,102 +181,48 @@ func main() {
|
||||
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
|
||||
|
||||
if *runSTUN {
|
||||
go serveSTUN(listenHost, *stunPort)
|
||||
go serveSTUN()
|
||||
}
|
||||
|
||||
quietLogger := log.New(logFilter{}, "", 0)
|
||||
httpsrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: mux,
|
||||
ErrorLog: quietLogger,
|
||||
|
||||
// Set read/write timeout. For derper, this basically
|
||||
// only affects TLS setup, as read/write deadlines are
|
||||
// cleared on Hijack, which the DERP server does. But
|
||||
// without this, we slowly accumulate stuck TLS
|
||||
// handshake goroutines forever. This also affects
|
||||
// /debug/ traffic, but 30 seconds is plenty for
|
||||
// Prometheus/etc scraping.
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
Addr: *addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
if serveTLS {
|
||||
var err error
|
||||
if letsEncrypt {
|
||||
if *certDir == "" {
|
||||
log.Fatalf("missing required --certdir flag")
|
||||
}
|
||||
log.Printf("derper: serving on %s with TLS", *addr)
|
||||
var certManager certProvider
|
||||
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname)
|
||||
if err != nil {
|
||||
log.Fatalf("derper: can not start cert provider: %v", err)
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(*hostname),
|
||||
Cache: autocert.DirCache(*certDir),
|
||||
}
|
||||
if *hostname == "derp.tailscale.com" {
|
||||
certManager.HostPolicy = prodAutocertHostPolicy
|
||||
certManager.Email = "security@tailscale.com"
|
||||
}
|
||||
httpsrv.TLSConfig = certManager.TLSConfig()
|
||||
getCert := httpsrv.TLSConfig.GetCertificate
|
||||
letsEncryptGetCert := httpsrv.TLSConfig.GetCertificate
|
||||
httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := getCert(hi)
|
||||
cert, err := letsEncryptGetCert(hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert.Certificate = append(cert.Certificate, s.MetaCert())
|
||||
return cert, nil
|
||||
}
|
||||
// Disable TLS 1.0 and 1.1, which are obsolete and have security issues.
|
||||
httpsrv.TLSConfig.MinVersion = tls.VersionTLS12
|
||||
httpsrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil {
|
||||
label := "unknown"
|
||||
switch r.TLS.Version {
|
||||
case tls.VersionTLS10:
|
||||
label = "1.0"
|
||||
case tls.VersionTLS11:
|
||||
label = "1.1"
|
||||
case tls.VersionTLS12:
|
||||
label = "1.2"
|
||||
case tls.VersionTLS13:
|
||||
label = "1.3"
|
||||
go func() {
|
||||
err := http.ListenAndServe(":80", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
|
||||
if err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
tlsRequestVersion.Add(label, 1)
|
||||
tlsActiveVersion.Add(label, 1)
|
||||
defer tlsActiveVersion.Add(label, -1)
|
||||
}
|
||||
|
||||
// Set HTTP headers to appease automated security scanners.
|
||||
//
|
||||
// Security automation gets cranky when HTTPS sites don't
|
||||
// set HSTS, and when they don't specify a content
|
||||
// security policy for XSS mitigation.
|
||||
//
|
||||
// DERP's HTTP interface is only ever used for debug
|
||||
// access (for which trivial safe policies work just
|
||||
// fine), and by DERP clients which don't obey any of
|
||||
// these browser-centric headers anyway.
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'self'; block-all-mixed-content; plugin-types 'none'")
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
if *httpPort > -1 {
|
||||
go func() {
|
||||
port80mux := http.NewServeMux()
|
||||
port80mux.HandleFunc("/generate_204", serveNoContent)
|
||||
port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
|
||||
port80srv := &http.Server{
|
||||
Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)),
|
||||
Handler: port80mux,
|
||||
ErrorLog: quietLogger,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// Crank up WriteTimeout a bit more than usually
|
||||
// necessary just so we can do long CPU profiles
|
||||
// and not hit net/http/pprof's "profile
|
||||
// duration exceeds server's WriteTimeout".
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
}
|
||||
err := port80srv.ListenAndServe()
|
||||
if err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
err = rateLimitedListenAndServeTLS(httpsrv)
|
||||
}()
|
||||
err = httpsrv.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
log.Printf("derper: serving on %s", *addr)
|
||||
err = httpsrv.ListenAndServe()
|
||||
@@ -324,69 +232,45 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
noContentChallengeHeader = "X-Tailscale-Challenge"
|
||||
noContentResponseHeader = "X-Tailscale-Response"
|
||||
)
|
||||
|
||||
// For captive portal detection
|
||||
func serveNoContent(w http.ResponseWriter, r *http.Request) {
|
||||
if challenge := r.Header.Get(noContentChallengeHeader); challenge != "" {
|
||||
badChar := strings.IndexFunc(challenge, func(r rune) bool {
|
||||
return !isChallengeChar(r)
|
||||
}) != -1
|
||||
if len(challenge) <= 64 && !badChar {
|
||||
w.Header().Set(noContentResponseHeader, "response "+challenge)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func isChallengeChar(c rune) bool {
|
||||
// Semi-randomly chosen as a limited set of valid characters
|
||||
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
||||
('0' <= c && c <= '9') ||
|
||||
c == '.' || c == '-' || c == '_'
|
||||
}
|
||||
|
||||
// probeHandler is the endpoint that js/wasm clients hit to measure
|
||||
// DERP latency, since they can't do UDP STUN queries.
|
||||
func probeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "HEAD", "GET":
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
default:
|
||||
http.Error(w, "bogus probe method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func serveSTUN(host string, port int) {
|
||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(host, fmt.Sprint(port)))
|
||||
func serveSTUN() {
|
||||
pc, err := net.ListenPacket("udp", ":3478")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open STUN listener: %v", err)
|
||||
}
|
||||
log.Printf("running STUN server on %v", pc.LocalAddr())
|
||||
serverSTUNListener(context.Background(), pc.(*net.UDPConn))
|
||||
}
|
||||
|
||||
func serverSTUNListener(ctx context.Context, pc *net.UDPConn) {
|
||||
var buf [64 << 10]byte
|
||||
var (
|
||||
n int
|
||||
ua *net.UDPAddr
|
||||
err error
|
||||
stats = new(metrics.Set)
|
||||
stunDisposition = &metrics.LabelMap{Label: "disposition"}
|
||||
stunAddrFamily = &metrics.LabelMap{Label: "family"}
|
||||
|
||||
stunReadError = stunDisposition.Get("read_error")
|
||||
stunNotSTUN = stunDisposition.Get("not_stun")
|
||||
stunWriteError = stunDisposition.Get("write_error")
|
||||
stunSuccess = stunDisposition.Get("success")
|
||||
|
||||
stunIPv4 = stunAddrFamily.Get("ipv4")
|
||||
stunIPv6 = stunAddrFamily.Get("ipv6")
|
||||
)
|
||||
stats.Set("counter_requests", stunDisposition)
|
||||
stats.Set("counter_addrfamily", stunAddrFamily)
|
||||
expvar.Publish("stun", stats)
|
||||
|
||||
var buf [64 << 10]byte
|
||||
for {
|
||||
n, ua, err = pc.ReadFromUDP(buf[:])
|
||||
n, addr, err := pc.ReadFrom(buf[:])
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("STUN ReadFrom: %v", err)
|
||||
time.Sleep(time.Second)
|
||||
stunReadError.Add(1)
|
||||
continue
|
||||
}
|
||||
ua, ok := addr.(*net.UDPAddr)
|
||||
if !ok {
|
||||
log.Printf("STUN unexpected address %T %v", addr, addr)
|
||||
stunReadError.Add(1)
|
||||
continue
|
||||
}
|
||||
pkt := buf[:n]
|
||||
if !stun.Is(pkt) {
|
||||
stunNotSTUN.Add(1)
|
||||
@@ -402,9 +286,8 @@ func serverSTUNListener(ctx context.Context, pc *net.UDPConn) {
|
||||
} else {
|
||||
stunIPv6.Add(1)
|
||||
}
|
||||
addr, _ := netip.AddrFromSlice(ua.IP)
|
||||
res := stun.Response(txid, netip.AddrPortFrom(addr, uint16(ua.Port)))
|
||||
_, err = pc.WriteTo(res, ua)
|
||||
res := stun.Response(txid, ua.IP, uint16(ua.Port))
|
||||
_, err = pc.WriteTo(res, addr)
|
||||
if err != nil {
|
||||
stunWriteError.Add(1)
|
||||
} else {
|
||||
@@ -434,82 +317,3 @@ func defaultMeshPSKFile() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rateLimitedListenAndServeTLS(srv *http.Server) error {
|
||||
addr := srv.Addr
|
||||
if addr == "" {
|
||||
addr = ":https"
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rln := newRateLimitedListener(ln, rate.Limit(*acceptConnLimit), *acceptConnBurst)
|
||||
expvar.Publish("tls_listener", rln.ExpVar())
|
||||
defer rln.Close()
|
||||
return srv.ServeTLS(rln, "", "")
|
||||
}
|
||||
|
||||
type rateLimitedListener struct {
|
||||
// These are at the start of the struct to ensure 64-bit alignment
|
||||
// on 32-bit architecture regardless of what other fields may exist
|
||||
// in this package.
|
||||
numAccepts expvar.Int // does not include number of rejects
|
||||
numRejects expvar.Int
|
||||
|
||||
net.Listener
|
||||
|
||||
lim *rate.Limiter
|
||||
}
|
||||
|
||||
func newRateLimitedListener(ln net.Listener, limit rate.Limit, burst int) *rateLimitedListener {
|
||||
return &rateLimitedListener{Listener: ln, lim: rate.NewLimiter(limit, burst)}
|
||||
}
|
||||
|
||||
func (l *rateLimitedListener) ExpVar() expvar.Var {
|
||||
m := new(metrics.Set)
|
||||
m.Set("counter_accepted_connections", &l.numAccepts)
|
||||
m.Set("counter_rejected_connections", &l.numRejects)
|
||||
return m
|
||||
}
|
||||
|
||||
var errLimitedConn = errors.New("cannot accept connection; rate limited")
|
||||
|
||||
func (l *rateLimitedListener) Accept() (net.Conn, error) {
|
||||
// Even under a rate limited situation, we accept the connection immediately
|
||||
// and close it, rather than being slow at accepting new connections.
|
||||
// This provides two benefits: 1) it signals to the client that something
|
||||
// is going on on the server, and 2) it prevents new connections from
|
||||
// piling up and occupying resources in the OS kernel.
|
||||
// The client will retry as needing (with backoffs in place).
|
||||
cn, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !l.lim.Allow() {
|
||||
l.numRejects.Add(1)
|
||||
cn.Close()
|
||||
return nil, errLimitedConn
|
||||
}
|
||||
l.numAccepts.Add(1)
|
||||
return cn, nil
|
||||
}
|
||||
|
||||
// logFilter is used to filter out useless error logs that are logged to
|
||||
// the net/http.Server.ErrorLog logger.
|
||||
type logFilter struct{}
|
||||
|
||||
func (logFilter) Write(p []byte) (int, error) {
|
||||
b := mem.B(p)
|
||||
if mem.HasSuffix(b, mem.S(": EOF\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": i/o timeout\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": read: connection reset by peer\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": remote error: tls: bad certificate\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": tls: first record does not look like a TLS handshake\n")) {
|
||||
// Skip this log message, but say that we processed it
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
log.Printf("%s", p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/stun"
|
||||
)
|
||||
|
||||
func TestProdAutocertHostPolicy(t *testing.T) {
|
||||
@@ -36,95 +31,5 @@ func TestProdAutocertHostPolicy(t *testing.T) {
|
||||
t.Errorf("f(%q) = %v; want %v", tt.in, got, tt.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServerSTUN(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer pc.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go serverSTUNListener(ctx, pc.(*net.UDPConn))
|
||||
addr := pc.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
var resBuf [1500]byte
|
||||
cc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
tx := stun.NewTxID()
|
||||
req := stun.Request(tx)
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := cc.WriteToUDP(req, addr); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _, err := cc.ReadFromUDP(resBuf[:])
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNoContent(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no challenge",
|
||||
},
|
||||
{
|
||||
name: "valid challenge",
|
||||
input: "input",
|
||||
want: "response input",
|
||||
},
|
||||
{
|
||||
name: "valid challenge hostname",
|
||||
input: "ts_derp99b.tailscale.com",
|
||||
want: "response ts_derp99b.tailscale.com",
|
||||
},
|
||||
{
|
||||
name: "invalid challenge",
|
||||
input: "foo\x00bar",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace invalid challenge",
|
||||
input: "foo bar",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "long challenge",
|
||||
input: strings.Repeat("x", 65),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil)
|
||||
if tt.input != "" {
|
||||
req.Header.Set(noContentChallengeHeader, tt.input)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
serveNoContent(w, req)
|
||||
resp := w.Result()
|
||||
|
||||
if tt.want == "" {
|
||||
if h, found := resp.Header[noContentResponseHeader]; found {
|
||||
t.Errorf("got %+v; expected no response header", h)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if got := resp.Header.Get(noContentResponseHeader); got != tt.want {
|
||||
t.Errorf("got %q; want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
@@ -49,7 +50,8 @@ func startMeshWithHost(s *derp.Server, host string) error {
|
||||
}
|
||||
var d net.Dialer
|
||||
var r net.Resolver
|
||||
if base, ok := strings.CutSuffix(host, ".tailscale.com"); ok && port == "443" {
|
||||
if port == "443" && strings.HasSuffix(host, ".tailscale.com") {
|
||||
base := strings.TrimSuffix(host, ".tailscale.com")
|
||||
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
vpcHost := base + "-vpc.tailscale.com"
|
||||
@@ -67,8 +69,8 @@ func startMeshWithHost(s *derp.Server, host string) error {
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
|
||||
add := func(k key.NodePublic) { s.AddPacketForwarder(k, c) }
|
||||
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
|
||||
add := func(k key.Public) { s.AddPacketForwarder(k, c) }
|
||||
remove := func(k key.Public) { s.RemovePacketForwarder(k, c) }
|
||||
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"expvar"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/net/wsconn"
|
||||
)
|
||||
|
||||
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
|
||||
|
||||
// addWebSocketSupport returns a Handle wrapping base that adds WebSocket server support.
|
||||
func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
up := strings.ToLower(r.Header.Get("Upgrade"))
|
||||
|
||||
// Very early versions of Tailscale set "Upgrade: WebSocket" but didn't actually
|
||||
// speak WebSockets (they still assumed DERP's binary framing). So to distinguish
|
||||
// clients that actually want WebSockets, look for an explicit "derp" subprotocol.
|
||||
if up != "websocket" || !strings.Contains(r.Header.Get("Sec-Websocket-Protocol"), "derp") {
|
||||
base.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
Subprotocols: []string{"derp"},
|
||||
OriginPatterns: []string{"*"},
|
||||
// Disable compression because we transmit WireGuard messages that
|
||||
// are not compressible.
|
||||
// Additionally, Safari has a broken implementation of compression
|
||||
// (see https://github.com/nhooyr/websocket/issues/218) that makes
|
||||
// enabling it actively harmful.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("websocket.Accept: %v", err)
|
||||
return
|
||||
}
|
||||
defer c.Close(websocket.StatusInternalError, "closing")
|
||||
if c.Subprotocol() != "derp" {
|
||||
c.Close(websocket.StatusPolicyViolation, "client must speak the derp subprotocol")
|
||||
return
|
||||
}
|
||||
counterWebSocketAccepts.Add(1)
|
||||
wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary)
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
|
||||
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The derpprobe binary probes derpers.
|
||||
package main
|
||||
package main // import "tailscale.com/cmd/derper/derpprobe"
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -13,99 +17,338 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/prober"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
var (
|
||||
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
|
||||
listen = flag.String("listen", ":8030", "HTTP listen address")
|
||||
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
|
||||
interval = flag.Duration("interval", 15*time.Second, "probe interval")
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
state = map[nodePair]pairStatus{}
|
||||
lastDERPMap *tailcfg.DERPMap
|
||||
lastDERPMapAt time.Time
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
p := prober.New().WithSpread(true).WithOnce(*probeOnce)
|
||||
dp, err := prober.DERP(p, *derpMapURL, *interval, *interval, *interval)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
p.Run("derpmap-probe", *interval, nil, dp.ProbeMap)
|
||||
|
||||
if *probeOnce {
|
||||
log.Printf("Waiting for all probes (may take up to 1m)")
|
||||
p.Wait()
|
||||
|
||||
st := getOverallStatus(p)
|
||||
for _, s := range st.good {
|
||||
log.Printf("good: %s", s)
|
||||
}
|
||||
for _, s := range st.bad {
|
||||
log.Printf("bad: %s", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
expvar.Publish("derpprobe", p.Expvar())
|
||||
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
|
||||
log.Fatal(http.ListenAndServe(*listen, mux))
|
||||
go probeLoop()
|
||||
log.Fatal(http.ListenAndServe(*listen, http.HandlerFunc(serve)))
|
||||
}
|
||||
|
||||
type overallStatus struct {
|
||||
good, bad []string
|
||||
}
|
||||
|
||||
func (st *overallStatus) addBadf(format string, a ...any) {
|
||||
func (st *overallStatus) addBadf(format string, a ...interface{}) {
|
||||
st.bad = append(st.bad, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
func (st *overallStatus) addGoodf(format string, a ...any) {
|
||||
func (st *overallStatus) addGoodf(format string, a ...interface{}) {
|
||||
st.good = append(st.good, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
func getOverallStatus(p *prober.Prober) (o overallStatus) {
|
||||
for p, i := range p.ProbeInfo() {
|
||||
if i.End.IsZero() {
|
||||
// Do not show probes that have not finished yet.
|
||||
continue
|
||||
}
|
||||
if i.Result {
|
||||
o.addGoodf("%s: %s", p, i.Latency)
|
||||
} else {
|
||||
o.addBadf("%s: %s", p, i.Error)
|
||||
func getOverallStatus() (o overallStatus) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if lastDERPMap == nil {
|
||||
o.addBadf("no DERP map")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if age := now.Sub(lastDERPMapAt); age > time.Minute {
|
||||
o.addBadf("DERPMap hasn't been successfully refreshed in %v", age.Round(time.Second))
|
||||
}
|
||||
for _, reg := range sortedRegions(lastDERPMap) {
|
||||
for _, from := range reg.Nodes {
|
||||
for _, to := range reg.Nodes {
|
||||
pair := nodePair{from.Name, to.Name}
|
||||
st, ok := state[pair]
|
||||
age := now.Sub(st.at).Round(time.Second)
|
||||
switch {
|
||||
case !ok:
|
||||
o.addBadf("no state for %v", pair)
|
||||
case st.err != nil:
|
||||
o.addBadf("%v: %v", pair, st.err)
|
||||
case age > 90*time.Second:
|
||||
o.addBadf("%v: update is %v old", pair, age)
|
||||
default:
|
||||
o.addGoodf("%v: %v, %v ago", pair, st.latency.Round(time.Millisecond), age)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(o.bad)
|
||||
sort.Strings(o.good)
|
||||
return
|
||||
}
|
||||
|
||||
func serveFunc(p *prober.Prober) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
st := getOverallStatus(p)
|
||||
summary := "All good"
|
||||
if (float64(len(st.bad)) / float64(len(st.bad)+len(st.good))) > 0.25 {
|
||||
// Returning a 500 allows monitoring this server externally and configuring
|
||||
// an alert on HTTP response code.
|
||||
w.WriteHeader(500)
|
||||
summary = fmt.Sprintf("%d problems", len(st.bad))
|
||||
}
|
||||
func serve(w http.ResponseWriter, r *http.Request) {
|
||||
st := getOverallStatus()
|
||||
summary := "All good"
|
||||
if len(st.bad) > 0 {
|
||||
w.WriteHeader(500)
|
||||
summary = fmt.Sprintf("%d problems", len(st.bad))
|
||||
}
|
||||
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
|
||||
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
|
||||
for _, s := range st.bad {
|
||||
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
for _, s := range st.good {
|
||||
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
io.WriteString(w, "</ul></body></html>\n")
|
||||
}
|
||||
|
||||
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
|
||||
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
|
||||
for _, s := range st.bad {
|
||||
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
for _, s := range st.good {
|
||||
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
io.WriteString(w, "</ul></body></html>\n")
|
||||
func sortedRegions(dm *tailcfg.DERPMap) []*tailcfg.DERPRegion {
|
||||
ret := make([]*tailcfg.DERPRegion, 0, len(dm.Regions))
|
||||
for _, r := range dm.Regions {
|
||||
ret = append(ret, r)
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool { return ret[i].RegionID < ret[j].RegionID })
|
||||
return ret
|
||||
}
|
||||
|
||||
type nodePair struct {
|
||||
from, to string // DERPNode.Name
|
||||
}
|
||||
|
||||
func (p nodePair) String() string { return fmt.Sprintf("(%s→%s)", p.from, p.to) }
|
||||
|
||||
type pairStatus struct {
|
||||
err error
|
||||
latency time.Duration
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func setDERPMap(dm *tailcfg.DERPMap) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lastDERPMap = dm
|
||||
lastDERPMapAt = time.Now()
|
||||
}
|
||||
|
||||
func setState(p nodePair, latency time.Duration, err error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
st := pairStatus{
|
||||
err: err,
|
||||
latency: latency,
|
||||
at: time.Now(),
|
||||
}
|
||||
state[p] = st
|
||||
if err != nil {
|
||||
log.Printf("%+v error: %v", p, err)
|
||||
} else {
|
||||
log.Printf("%+v: %v", p, latency.Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
func probeLoop() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
for {
|
||||
err := probe()
|
||||
if err != nil {
|
||||
log.Printf("probe: %v", err)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func probe() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
dm, err := getDERPMap(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(dm.Regions))
|
||||
for _, reg := range dm.Regions {
|
||||
reg := reg
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, from := range reg.Nodes {
|
||||
for _, to := range reg.Nodes {
|
||||
latency, err := probeNodePair(ctx, dm, from, to)
|
||||
setState(nodePair{from.Name, to.Name}, latency, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func probeNodePair(ctx context.Context, dm *tailcfg.DERPMap, from, to *tailcfg.DERPNode) (latency time.Duration, err error) {
|
||||
// The passed in context is a minute for the whole region. The
|
||||
// idea is that each node pair in the region will be done
|
||||
// serially and regularly in the future, reusing connections
|
||||
// (at least in the happy path). For now they don't reuse
|
||||
// connections and probe at most once every 15 seconds. We
|
||||
// bound the duration of a single node pair within a region
|
||||
// so one bad one can't starve others.
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fromc, err := newConn(ctx, dm, from)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer fromc.Close()
|
||||
toc, err := newConn(ctx, dm, to)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer toc.Close()
|
||||
|
||||
// Wait a bit for from's node to hear about to existing on the
|
||||
// other node in the region, in the case where the two nodes
|
||||
// are different.
|
||||
if from.Name != to.Name {
|
||||
time.Sleep(100 * time.Millisecond) // pretty arbitrary
|
||||
}
|
||||
|
||||
// Make a random packet
|
||||
pkt := make([]byte, 8)
|
||||
crand.Read(pkt)
|
||||
|
||||
t0 := time.Now()
|
||||
|
||||
// Send the random packet.
|
||||
sendc := make(chan error, 1)
|
||||
go func() {
|
||||
sendc <- fromc.Send(toc.SelfPublicKey(), pkt)
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, fmt.Errorf("timeout sending via %q: %w", from.Name, ctx.Err())
|
||||
case err := <-sendc:
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error sending via %q: %w", from.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Receive the random packet.
|
||||
recvc := make(chan interface{}, 1) // either derp.ReceivedPacket or error
|
||||
go func() {
|
||||
for {
|
||||
m, err := toc.Recv()
|
||||
if err != nil {
|
||||
recvc <- err
|
||||
return
|
||||
}
|
||||
switch v := m.(type) {
|
||||
case derp.ReceivedPacket:
|
||||
recvc <- v
|
||||
default:
|
||||
log.Printf("%v: ignoring Recv frame type %T", to.Name, v)
|
||||
// Loop.
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, fmt.Errorf("timeout receiving from %q: %w", to.Name, ctx.Err())
|
||||
case v := <-recvc:
|
||||
if err, ok := v.(error); ok {
|
||||
return 0, fmt.Errorf("error receiving from %q: %w", to.Name, err)
|
||||
}
|
||||
p := v.(derp.ReceivedPacket)
|
||||
if p.Source != fromc.SelfPublicKey() {
|
||||
return 0, fmt.Errorf("got data packet from unexpected source, %v", p.Source)
|
||||
}
|
||||
if !bytes.Equal(p.Data, pkt) {
|
||||
return 0, fmt.Errorf("unexpected data packet %q", p.Data)
|
||||
}
|
||||
}
|
||||
return time.Since(t0), nil
|
||||
}
|
||||
|
||||
func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode) (*derphttp.Client, error) {
|
||||
priv := key.NewPrivate()
|
||||
dc := derphttp.NewRegionClient(priv, log.Printf, func() *tailcfg.DERPRegion {
|
||||
rid := n.RegionID
|
||||
return &tailcfg.DERPRegion{
|
||||
RegionID: rid,
|
||||
RegionCode: fmt.Sprintf("%s-%s", dm.Regions[rid].RegionCode, n.Name),
|
||||
RegionName: dm.Regions[rid].RegionName,
|
||||
Nodes: []*tailcfg.DERPNode{n},
|
||||
}
|
||||
})
|
||||
dc.IsProber = true
|
||||
err := dc.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
m, err := dc.Recv()
|
||||
if err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
switch m.(type) {
|
||||
case derp.ServerInfoMessage:
|
||||
errc <- nil
|
||||
default:
|
||||
errc <- fmt.Errorf("unexpected first message type %T", errc)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case err := <-errc:
|
||||
if err != nil {
|
||||
go dc.Close()
|
||||
return nil, err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
go dc.Close()
|
||||
return nil, fmt.Errorf("timeout waiting for ServerInfoMessage: %w", ctx.Err())
|
||||
}
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
var httpOrFileClient = &http.Client{Transport: httpOrFileTransport()}
|
||||
|
||||
func httpOrFileTransport() http.RoundTripper {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.RegisterProtocol("file", http.NewFileTransport(http.Dir("/")))
|
||||
return tr
|
||||
}
|
||||
|
||||
func getDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", *derpMapURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := httpOrFileClient.Do(req)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if lastDERPMap != nil && time.Since(lastDERPMapAt) < 10*time.Minute {
|
||||
// Assume that control is restarting and use
|
||||
// the same one for a bit.
|
||||
return lastDERPMap, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("fetching %s: %s", *derpMapURL, res.Status)
|
||||
}
|
||||
dm := new(tailcfg.DERPMap)
|
||||
if err := json.NewDecoder(res.Body).Decode(dm); err != nil {
|
||||
return nil, fmt.Errorf("decoding %s JSON: %v", *derpMapURL, err)
|
||||
}
|
||||
setDERPMap(dm)
|
||||
return dm, nil
|
||||
}
|
||||
|
||||
1
cmd/get-authkey/.gitignore
vendored
1
cmd/get-authkey/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
get-authkey
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// get-authkey allocates an authkey using an OAuth API client
|
||||
// https://tailscale.com/kb/1215/oauth-clients/ and prints it
|
||||
// to stdout for scripts to capture and use.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Required to use our client API. We're fine with the instability since the
|
||||
// client lives in the same repo as this code.
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
reusable := flag.Bool("reusable", false, "allocate a reusable authkey")
|
||||
ephemeral := flag.Bool("ephemeral", false, "allocate an ephemeral authkey")
|
||||
preauth := flag.Bool("preauth", true, "set the authkey as pre-authorized")
|
||||
tags := flag.String("tags", "", "comma-separated list of tags to apply to the authkey")
|
||||
flag.Parse()
|
||||
|
||||
clientId := os.Getenv("TS_API_CLIENT_ID")
|
||||
clientSecret := os.Getenv("TS_API_CLIENT_SECRET")
|
||||
if clientId == "" || clientSecret == "" {
|
||||
log.Fatal("TS_API_CLIENT_ID and TS_API_CLIENT_SECRET must be set")
|
||||
}
|
||||
|
||||
if *tags == "" {
|
||||
log.Fatal("at least one tag must be specified")
|
||||
}
|
||||
|
||||
baseUrl := os.Getenv("TS_BASE_URL")
|
||||
if baseUrl == "" {
|
||||
baseUrl = "https://api.tailscale.com"
|
||||
}
|
||||
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: baseUrl + "/api/v2/oauth/token",
|
||||
Scopes: []string{"device"},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.HTTPClient = credentials.Client(ctx)
|
||||
tsClient.BaseURL = baseUrl
|
||||
|
||||
caps := tailscale.KeyCapabilities{
|
||||
Devices: tailscale.KeyDeviceCapabilities{
|
||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
||||
Reusable: *reusable,
|
||||
Ephemeral: *ephemeral,
|
||||
Preauthorized: *preauth,
|
||||
Tags: strings.Split(*tags, ","),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
authkey, _, err := tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
|
||||
fmt.Println(authkey)
|
||||
}
|
||||
1
cmd/gitops-pusher/.gitignore
vendored
1
cmd/gitops-pusher/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
version-cache.json
|
||||
@@ -1,48 +0,0 @@
|
||||
# gitops-pusher
|
||||
|
||||
This is a small tool to help people achieve a
|
||||
[GitOps](https://about.gitlab.com/topics/gitops/) workflow with Tailscale ACL
|
||||
changes. This tool is intended to be used in a CI flow that looks like this:
|
||||
|
||||
```yaml
|
||||
name: Tailscale ACL syncing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
acls:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Go environment
|
||||
uses: actions/setup-go@v3.2.0
|
||||
|
||||
- name: Install gitops-pusher
|
||||
run: go install tailscale.com/cmd/gitops-pusher@latest
|
||||
|
||||
- name: Deploy ACL
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
TS_API_KEY: ${{ secrets.TS_API_KEY }}
|
||||
TS_TAILNET: ${{ secrets.TS_TAILNET }}
|
||||
run: |
|
||||
~/go/bin/gitops-pusher --policy-file ./policy.hujson apply
|
||||
|
||||
- name: ACL tests
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
TS_API_KEY: ${{ secrets.TS_API_KEY }}
|
||||
TS_TAILNET: ${{ secrets.TS_TAILNET }}
|
||||
run: |
|
||||
~/go/bin/gitops-pusher --policy-file ./policy.hujson test
|
||||
```
|
||||
|
||||
Change the value of the `--policy-file` flag to point to the policy file on
|
||||
disk. Policy files should be in [HuJSON](https://github.com/tailscale/hujson)
|
||||
format.
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Cache contains cached information about the last time this tool was run.
|
||||
//
|
||||
// This is serialized to a JSON file that should NOT be checked into git.
|
||||
// It should be managed with either CI cache tools or stored locally somehow. The
|
||||
// exact mechanism is irrelevant as long as it is consistent.
|
||||
//
|
||||
// This allows gitops-pusher to detect external ACL changes. I'm not sure what to
|
||||
// call this problem, so I've been calling it the "three version problem" in my
|
||||
// notes. The basic problem is that at any given time we only have two versions
|
||||
// of the ACL file at any given point. In order to check if there has been
|
||||
// tampering of the ACL files in the admin panel, we need to have a _third_ version
|
||||
// to compare against.
|
||||
//
|
||||
// In this case I am not storing the old ACL entirely (though that could be a
|
||||
// reasonable thing to add in the future), but only its sha256sum. This allows
|
||||
// us to detect if the shasum in control matches the shasum we expect, and if that
|
||||
// expectation fails, then we can react accordingly.
|
||||
type Cache struct {
|
||||
PrevETag string // Stores the previous ETag of the ACL to allow
|
||||
}
|
||||
|
||||
// Save persists the cache to a given file.
|
||||
func (c *Cache) Save(fname string) error {
|
||||
os.Remove(fname)
|
||||
fout, err := os.Create(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fout.Close()
|
||||
|
||||
return json.NewEncoder(fout).Encode(c)
|
||||
}
|
||||
|
||||
// LoadCache loads the cache from a given file.
|
||||
func LoadCache(fname string) (*Cache, error) {
|
||||
var result Cache
|
||||
|
||||
fin, err := os.Open(fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
err = json.NewDecoder(fin).Decode(&result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Shuck removes the first and last character of a string, analogous to
|
||||
// shucking off the husk of an ear of corn.
|
||||
func Shuck(s string) string {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs.
|
||||
//
|
||||
// See README.md for more details.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"github.com/tailscale/hujson"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
var (
|
||||
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
|
||||
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
|
||||
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
|
||||
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
|
||||
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
|
||||
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
|
||||
)
|
||||
|
||||
func modifiedExternallyError() {
|
||||
if *githubSyntax {
|
||||
fmt.Printf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n", *policyFname)
|
||||
} else {
|
||||
fmt.Printf("The policy file was modified externally in the admin console.\n")
|
||||
}
|
||||
}
|
||||
|
||||
func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
|
||||
return func(ctx context.Context, args []string) error {
|
||||
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEtag, err := sumFile(*policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = localEtag
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
modifiedExternallyError()
|
||||
}
|
||||
|
||||
if controlEtag == localEtag {
|
||||
cache.PrevETag = localEtag
|
||||
log.Println("no update needed, doing nothing")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cache.PrevETag = localEtag
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func test(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
|
||||
return func(ctx context.Context, args []string) error {
|
||||
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEtag, err := sumFile(*policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = localEtag
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
modifiedExternallyError()
|
||||
}
|
||||
|
||||
if controlEtag == localEtag {
|
||||
log.Println("no updates found, doing nothing")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getChecksums(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
|
||||
return func(ctx context.Context, args []string) error {
|
||||
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEtag, err := sumFile(*policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = Shuck(localEtag)
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
tailnet, ok := os.LookupEnv("TS_TAILNET")
|
||||
if !ok {
|
||||
log.Fatal("set envvar TS_TAILNET to your tailnet's name")
|
||||
}
|
||||
apiKey, ok := os.LookupEnv("TS_API_KEY")
|
||||
if !ok {
|
||||
log.Fatal("set envvar TS_API_KEY to your Tailscale API key")
|
||||
}
|
||||
cache, err := LoadCache(*cacheFname)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cache = &Cache{}
|
||||
} else {
|
||||
log.Fatalf("error loading cache: %v", err)
|
||||
}
|
||||
}
|
||||
defer cache.Save(*cacheFname)
|
||||
|
||||
applyCmd := &ffcli.Command{
|
||||
Name: "apply",
|
||||
ShortUsage: "gitops-pusher [options] apply",
|
||||
ShortHelp: "Pushes changes to CONTROL",
|
||||
LongHelp: `Pushes changes to CONTROL`,
|
||||
Exec: apply(cache, tailnet, apiKey),
|
||||
}
|
||||
|
||||
testCmd := &ffcli.Command{
|
||||
Name: "test",
|
||||
ShortUsage: "gitops-pusher [options] test",
|
||||
ShortHelp: "Tests ACL changes",
|
||||
LongHelp: "Tests ACL changes",
|
||||
Exec: test(cache, tailnet, apiKey),
|
||||
}
|
||||
|
||||
cksumCmd := &ffcli.Command{
|
||||
Name: "checksum",
|
||||
ShortUsage: "Shows checksums of ACL files",
|
||||
ShortHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
|
||||
LongHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
|
||||
Exec: getChecksums(cache, tailnet, apiKey),
|
||||
}
|
||||
|
||||
root := &ffcli.Command{
|
||||
ShortUsage: "gitops-pusher [options] <command>",
|
||||
ShortHelp: "Push Tailscale ACLs to CONTROL using a GitOps workflow",
|
||||
Subcommands: []*ffcli.Command{applyCmd, cksumCmd, testCmd},
|
||||
FlagSet: rootFlagSet,
|
||||
}
|
||||
|
||||
if err := root.Parse(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := root.Run(ctx); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func sumFile(fname string) (string, error) {
|
||||
data, err := os.ReadFile(fname)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
formatted, err := hujson.Format(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
_, err = h.Write(formatted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error {
|
||||
fin, err := os.Open(policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), fin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(apiKey, "")
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
req.Header.Set("If-Match", `"`+oldEtag+`"`)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
var ate ACLTestError
|
||||
err := json.NewDecoder(resp.Body).Decode(&ate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ate
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error {
|
||||
data, err := os.ReadFile(policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err = hujson.Standardize(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl/validate", *apiServer, tailnet), bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(apiKey, "")
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var ate ACLTestError
|
||||
err = json.NewDecoder(resp.Body).Decode(&ate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(ate.Message) != 0 || len(ate.Data) != 0 {
|
||||
return ate
|
||||
}
|
||||
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
|
||||
|
||||
type ACLTestError struct {
|
||||
Message string `json:"message"`
|
||||
Data []ACLTestErrorDetail `json:"data"`
|
||||
}
|
||||
|
||||
func (ate ACLTestError) Error() string {
|
||||
var sb strings.Builder
|
||||
|
||||
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) {
|
||||
sp := lineColMessageSplit.FindStringSubmatch(ate.Message)
|
||||
|
||||
line := sp[1]
|
||||
col := sp[2]
|
||||
msg := sp[3]
|
||||
|
||||
fmt.Fprintf(&sb, "::error file=%s,line=%s,col=%s::%s", *policyFname, line, col, msg)
|
||||
} else {
|
||||
fmt.Fprintln(&sb, ate.Message)
|
||||
}
|
||||
fmt.Fprintln(&sb)
|
||||
|
||||
for _, data := range ate.Data {
|
||||
fmt.Fprintf(&sb, "For user %s:\n", data.User)
|
||||
for _, err := range data.Errors {
|
||||
fmt.Fprintf(&sb, "- %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type ACLTestErrorDetail struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(apiKey, "")
|
||||
req.Header.Set("Accept", "application/hujson")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
|
||||
}
|
||||
|
||||
return Shuck(resp.Header.Get("ETag")), nil
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The hello binary runs hello.ts.net.
|
||||
// The hello binary runs hello.ipn.dev.
|
||||
package main // import "tailscale.com/cmd/hello"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
@@ -70,31 +69,11 @@ func main() {
|
||||
if *httpsAddr != "" {
|
||||
log.Printf("running HTTPS server on %s", *httpsAddr)
|
||||
go func() {
|
||||
hs := &http.Server{
|
||||
Addr: *httpsAddr,
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
switch hi.ServerName {
|
||||
case "hello.ts.net":
|
||||
return tailscale.GetCertificate(hi)
|
||||
case "hello.ipn.dev":
|
||||
c, err := tls.LoadX509KeyPair(
|
||||
"/etc/hello/hello.ipn.dev.crt",
|
||||
"/etc/hello/hello.ipn.dev.key",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
return nil, errors.New("invalid SNI name")
|
||||
},
|
||||
},
|
||||
IdleTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 10 << 10,
|
||||
}
|
||||
errc <- hs.ListenAndServeTLS("", "")
|
||||
errc <- http.ListenAndServeTLS(*httpsAddr,
|
||||
"/etc/hello/hello.ipn.dev.crt",
|
||||
"/etc/hello/hello.ipn.dev.key",
|
||||
nil,
|
||||
)
|
||||
}()
|
||||
}
|
||||
log.Fatal(<-errc)
|
||||
@@ -104,7 +83,7 @@ func devMode() bool { return *httpsAddr == "" && *httpAddr != "" }
|
||||
|
||||
func getTmpl() (*template.Template, error) {
|
||||
if devMode() {
|
||||
tmplData, err := os.ReadFile("hello.tmpl.html")
|
||||
tmplData, err := ioutil.ReadFile("hello.tmpl.html")
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory")
|
||||
return tmpl, nil
|
||||
@@ -133,13 +112,13 @@ func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||
return ""
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
if nodeIP.IP().Is4() && nodeIP.IsSingleIP() {
|
||||
return nodeIP.IP().String()
|
||||
}
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
return nodeIP.IP().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -148,9 +127,8 @@ func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||
func root(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil && *httpsAddr != "" {
|
||||
host := r.Host
|
||||
if strings.Contains(r.Host, "100.101.102.103") ||
|
||||
strings.Contains(r.Host, "hello.ipn.dev") {
|
||||
host = "hello.ts.net"
|
||||
if strings.Contains(r.Host, "100.101.102.103") {
|
||||
host = "hello.ipn.dev"
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host, http.StatusFound)
|
||||
return
|
||||
@@ -159,10 +137,6 @@ func root(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if r.TLS != nil && *httpsAddr != "" && strings.Contains(r.Host, "hello.ipn.dev") {
|
||||
http.Redirect(w, r, "https://hello.ts.net", http.StatusFound)
|
||||
return
|
||||
}
|
||||
tmpl, err := getTmpl()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
@@ -194,7 +168,7 @@ func root(w http.ResponseWriter, r *http.Request) {
|
||||
LoginName: who.UserProfile.LoginName,
|
||||
ProfilePicURL: who.UserProfile.ProfilePicURL,
|
||||
MachineName: firstLabel(who.Node.ComputedName),
|
||||
MachineOS: who.Node.Hostinfo.OS(),
|
||||
MachineOS: who.Node.Hostinfo.OS,
|
||||
IP: tailscaleIP(who),
|
||||
}
|
||||
}
|
||||
@@ -204,6 +178,8 @@ func root(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// firstLabel s up until the first period, if any.
|
||||
func firstLabel(s string) string {
|
||||
s, _, _ = strings.Cut(s, ".")
|
||||
if i := strings.Index(s, "."); i != -1 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: tailscale-auth-proxy
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["users"]
|
||||
verbs: ["impersonate"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: tailscale-auth-proxy
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: tailscale-auth-proxy
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -1,156 +0,0 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: tailscale
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: proxies
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: tailscale-operator
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["services", "services/status"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: tailscale-operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: tailscale-operator
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: operator
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: operator-oauth
|
||||
namespace: tailscale
|
||||
stringData:
|
||||
client_id: # SET CLIENT ID HERE
|
||||
client_secret: # SET CLIENT SECRET HERE
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: operator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: operator
|
||||
spec:
|
||||
serviceAccountName: operator
|
||||
volumes:
|
||||
- name: oauth
|
||||
secret:
|
||||
secretName: operator-oauth
|
||||
containers:
|
||||
- name: operator
|
||||
image: tailscale/k8s-operator:unstable
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 100Mi
|
||||
env:
|
||||
- name: OPERATOR_HOSTNAME
|
||||
value: tailscale-operator
|
||||
- name: OPERATOR_SECRET
|
||||
value: operator
|
||||
- name: OPERATOR_LOGGING
|
||||
value: info
|
||||
- name: OPERATOR_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
value: /oauth/client_secret
|
||||
- name: PROXY_IMAGE
|
||||
value: tailscale/tailscale:unstable
|
||||
- name: PROXY_TAGS
|
||||
value: tag:k8s
|
||||
- name: AUTH_PROXY
|
||||
value: "false"
|
||||
volumeMounts:
|
||||
- name: oauth
|
||||
mountPath: /oauth
|
||||
readOnly: true
|
||||
@@ -1,37 +0,0 @@
|
||||
# This file is not a complete manifest, it's a skeleton that the operator embeds
|
||||
# at build time and then uses to construct Tailscale proxy pods.
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
spec:
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
deletionGracePeriodSeconds: 10
|
||||
spec:
|
||||
serviceAccountName: proxies
|
||||
initContainers:
|
||||
- name: sysctler
|
||||
image: busybox
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh"]
|
||||
args:
|
||||
- -c
|
||||
- sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1m
|
||||
memory: 1Mi
|
||||
containers:
|
||||
- name: tailscale
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: TS_USERSPACE
|
||||
value: "false"
|
||||
- name: TS_AUTH_ONCE
|
||||
value: "true"
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN
|
||||
@@ -1,715 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/zapr"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/rest"
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
"sigs.k8s.io/controller-runtime/pkg/cache"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/config"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
kzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
"sigs.k8s.io/controller-runtime/pkg/manager"
|
||||
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"sigs.k8s.io/controller-runtime/pkg/source"
|
||||
"sigs.k8s.io/yaml"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/kubestore"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Required to use our client API. We're fine with the instability since the
|
||||
// client lives in the same repo as this code.
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
var (
|
||||
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
|
||||
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
||||
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
||||
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
|
||||
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
|
||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
|
||||
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
|
||||
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
shouldRunAuthProxy = defaultEnv("AUTH_PROXY", "false")
|
||||
)
|
||||
|
||||
var opts []kzap.Opts
|
||||
switch tslogging {
|
||||
case "info":
|
||||
opts = append(opts, kzap.Level(zapcore.InfoLevel))
|
||||
case "debug":
|
||||
opts = append(opts, kzap.Level(zapcore.DebugLevel))
|
||||
case "dev":
|
||||
opts = append(opts, kzap.UseDevMode(true), kzap.Level(zapcore.DebugLevel))
|
||||
}
|
||||
zlog := kzap.NewRaw(opts...).Sugar()
|
||||
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
|
||||
startlog := zlog.Named("startup")
|
||||
|
||||
if clientIDPath == "" || clientSecretPath == "" {
|
||||
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set")
|
||||
}
|
||||
clientID, err := os.ReadFile(clientIDPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client ID %q: %v", clientIDPath, err)
|
||||
}
|
||||
clientSecret, err := os.ReadFile(clientSecretPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client secret %q: %v", clientSecretPath, err)
|
||||
}
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: string(clientID),
|
||||
ClientSecret: string(clientSecret),
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.HTTPClient = credentials.Client(context.Background())
|
||||
s := &tsnet.Server{
|
||||
Hostname: hostname,
|
||||
Logf: zlog.Named("tailscaled").Debugf,
|
||||
}
|
||||
if kubeSecret != "" {
|
||||
st, err := kubestore.New(logger.Discard, kubeSecret)
|
||||
if err != nil {
|
||||
startlog.Fatalf("creating kube store: %v", err)
|
||||
}
|
||||
s.Store = st
|
||||
}
|
||||
if err := s.Start(); err != nil {
|
||||
startlog.Fatalf("starting tailscale server: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
startlog.Fatalf("getting local client: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
loginDone := false
|
||||
machineAuthShown := false
|
||||
waitOnline:
|
||||
for {
|
||||
startlog.Debugf("querying tailscaled status")
|
||||
st, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
startlog.Fatalf("getting status: %v", err)
|
||||
}
|
||||
switch st.BackendState {
|
||||
case "Running":
|
||||
break waitOnline
|
||||
case "NeedsLogin":
|
||||
if loginDone {
|
||||
break
|
||||
}
|
||||
caps := tailscale.KeyCapabilities{
|
||||
Devices: tailscale.KeyDeviceCapabilities{
|
||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
||||
Reusable: false,
|
||||
Preauthorized: true,
|
||||
Tags: strings.Split(operatorTags, ","),
|
||||
},
|
||||
},
|
||||
}
|
||||
authkey, _, err := tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
startlog.Fatalf("creating operator authkey: %v", err)
|
||||
}
|
||||
if err := lc.Start(ctx, ipn.Options{
|
||||
AuthKey: authkey,
|
||||
}); err != nil {
|
||||
startlog.Fatalf("starting tailscale: %v", err)
|
||||
}
|
||||
if err := lc.StartLoginInteractive(ctx); err != nil {
|
||||
startlog.Fatalf("starting login: %v", err)
|
||||
}
|
||||
startlog.Debugf("requested login by authkey")
|
||||
loginDone = true
|
||||
case "NeedsMachineAuth":
|
||||
if !machineAuthShown {
|
||||
startlog.Infof("Machine authorization required, please visit the admin panel to authorize")
|
||||
machineAuthShown = true
|
||||
}
|
||||
default:
|
||||
startlog.Debugf("waiting for tailscale to start: %v", st.BackendState)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
// For secrets and statefulsets, we only get permission to touch the objects
|
||||
// in the controller's own namespace. This cannot be expressed by
|
||||
// .Watches(...) below, instead you have to add a per-type field selector to
|
||||
// the cache that sits a few layers below the builder stuff, which will
|
||||
// implicitly filter what parts of the world the builder code gets to see at
|
||||
// all.
|
||||
nsFilter := cache.ObjectSelector{
|
||||
Field: fields.SelectorFromSet(fields.Set{"metadata.namespace": tsNamespace}),
|
||||
}
|
||||
restConfig := config.GetConfigOrDie()
|
||||
mgr, err := manager.New(restConfig, manager.Options{
|
||||
NewCache: cache.BuilderWithOptions(cache.Options{
|
||||
SelectorsByObject: map[client.Object]cache.ObjectSelector{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
},
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create manager: %v", err)
|
||||
}
|
||||
|
||||
sr := &ServiceReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsClient: tsClient,
|
||||
defaultTags: strings.Split(tags, ","),
|
||||
operatorNamespace: tsNamespace,
|
||||
proxyImage: image,
|
||||
logger: zlog.Named("service-reconciler"),
|
||||
}
|
||||
|
||||
reconcileFilter := handler.EnqueueRequestsFromMapFunc(func(o client.Object) []reconcile.Request {
|
||||
ls := o.GetLabels()
|
||||
if ls[LabelManaged] != "true" {
|
||||
return nil
|
||||
}
|
||||
if ls[LabelParentType] != "svc" {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{
|
||||
{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: ls[LabelParentNamespace],
|
||||
Name: ls[LabelParentName],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
For(&corev1.Service{}).
|
||||
Watches(&source.Kind{Type: &appsv1.StatefulSet{}}, reconcileFilter).
|
||||
Watches(&source.Kind{Type: &corev1.Secret{}}, reconcileFilter).
|
||||
Complete(sr)
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create controller: %v", err)
|
||||
}
|
||||
|
||||
startlog.Infof("Startup complete, operator running")
|
||||
if shouldRunAuthProxy == "true" {
|
||||
rc, err := rest.TransportFor(restConfig)
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not get rest transport: %v", err)
|
||||
}
|
||||
authProxyListener, err := s.Listen("tcp", ":443")
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not listen on :443: %v", err)
|
||||
}
|
||||
go runAuthProxy(lc, authProxyListener, rc, zlog.Named("auth-proxy").Infof)
|
||||
}
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
startlog.Fatalf("could not start manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
LabelManaged = "tailscale.com/managed"
|
||||
LabelParentType = "tailscale.com/parent-resource-type"
|
||||
LabelParentName = "tailscale.com/parent-resource"
|
||||
LabelParentNamespace = "tailscale.com/parent-resource-ns"
|
||||
|
||||
FinalizerName = "tailscale.com/finalizer"
|
||||
|
||||
AnnotationExpose = "tailscale.com/expose"
|
||||
AnnotationTags = "tailscale.com/tags"
|
||||
AnnotationHostname = "tailscale.com/hostname"
|
||||
)
|
||||
|
||||
// ServiceReconciler is a simple ControllerManagedBy example implementation.
|
||||
type ServiceReconciler struct {
|
||||
client.Client
|
||||
tsClient tsClient
|
||||
defaultTags []string
|
||||
operatorNamespace string
|
||||
proxyImage string
|
||||
logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
type tsClient interface {
|
||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
||||
DeleteDevice(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func childResourceLabels(parent *corev1.Service) map[string]string {
|
||||
// You might wonder why we're using owner references, since they seem to be
|
||||
// built for exactly this. Unfortunately, Kubernetes does not support
|
||||
// cross-namespace ownership, by design. This means we cannot make the
|
||||
// service being exposed the owner of the implementation details of the
|
||||
// proxying. Instead, we have to do our own filtering and tracking with
|
||||
// labels.
|
||||
return map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: parent.GetName(),
|
||||
LabelParentNamespace: parent.GetNamespace(),
|
||||
LabelParentType: "svc",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
|
||||
logger := a.logger.With("service-ns", req.Namespace, "service-name", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
svc := new(corev1.Service)
|
||||
err = a.Get(ctx, req.NamespacedName, svc)
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Request object not found, could have been deleted after reconcile request.
|
||||
logger.Debugf("service not found, assuming it was deleted")
|
||||
return reconcile.Result{}, nil
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
|
||||
}
|
||||
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) {
|
||||
logger.Debugf("service is being deleted or should not be exposed, cleaning up")
|
||||
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
|
||||
}
|
||||
|
||||
return reconcile.Result{}, a.maybeProvision(ctx, logger, svc)
|
||||
}
|
||||
|
||||
// maybeCleanup removes any existing resources related to serving svc over tailscale.
|
||||
//
|
||||
// This function is responsible for removing the finalizer from the service,
|
||||
// once all associated resources are gone.
|
||||
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
|
||||
ix := slices.Index(svc.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return nil
|
||||
}
|
||||
|
||||
ml := childResourceLabels(svc)
|
||||
|
||||
// Need to delete the StatefulSet first, and delete it with foreground
|
||||
// cascading deletion. That way, the pod that's writing to the Secret will
|
||||
// stop running before we start looking at the Secret's contents, and
|
||||
// assuming k8s ordering semantics don't mess with us, that should avoid
|
||||
// tailscale device deletion races where we fail to notice a device that
|
||||
// should be removed.
|
||||
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, ml)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting statefulset: %w", err)
|
||||
}
|
||||
if sts != nil {
|
||||
if !sts.GetDeletionTimestamp().IsZero() {
|
||||
// Deletion in progress, check again later. We'll get another
|
||||
// notification when the deletion is complete.
|
||||
logger.Debugf("waiting for statefulset %s/%s deletion", sts.GetNamespace(), sts.GetName())
|
||||
return nil
|
||||
}
|
||||
err := a.DeleteAllOf(ctx, &appsv1.StatefulSet{}, client.InNamespace(a.operatorNamespace), client.MatchingLabels(ml), client.PropagationPolicy(metav1.DeletePropagationForeground))
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting statefulset: %w", err)
|
||||
}
|
||||
logger.Debugf("started deletion of statefulset %s/%s", sts.GetNamespace(), sts.GetName())
|
||||
return nil
|
||||
}
|
||||
|
||||
id, _, err := a.getDeviceInfo(ctx, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting device info: %w", err)
|
||||
}
|
||||
if id != "" {
|
||||
// TODO: handle case where the device is already deleted, but the secret
|
||||
// is still around.
|
||||
if err := a.tsClient.DeleteDevice(ctx, id); err != nil {
|
||||
return fmt.Errorf("deleting device: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
types := []client.Object{
|
||||
&corev1.Service{},
|
||||
&corev1.Secret{},
|
||||
}
|
||||
for _, typ := range types {
|
||||
if err := a.DeleteAllOf(ctx, typ, client.InNamespace(a.operatorNamespace), client.MatchingLabels(ml)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
svc.Finalizers = append(svc.Finalizers[:ix], svc.Finalizers[ix+1:]...)
|
||||
if err := a.Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to remove finalizer: %w", err)
|
||||
}
|
||||
|
||||
// Unlike most log entries in the reconcile loop, this will get printed
|
||||
// exactly once at the very end of cleanup, because the final step of
|
||||
// cleanup removes the tailscale finalizer, which will make all future
|
||||
// reconciles exit early.
|
||||
logger.Infof("unexposed service from tailnet")
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeProvision ensures that svc is exposed over tailscale, taking any actions
|
||||
// necessary to reach that state.
|
||||
//
|
||||
// This function adds a finalizer to svc, ensuring that we can handle orderly
|
||||
// deprovisioning later.
|
||||
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
|
||||
hostname, err := nameForService(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(svc.Finalizers, FinalizerName) {
|
||||
// This log line is printed exactly once during initial provisioning,
|
||||
// because once the finalizer is in place this block gets skipped. So,
|
||||
// this is a nice place to tell the operator that the high level,
|
||||
// multi-reconcile operation is underway.
|
||||
logger.Infof("exposing service over tailscale")
|
||||
svc.Finalizers = append(svc.Finalizers, FinalizerName)
|
||||
if err := a.Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to add finalizer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Do full reconcile.
|
||||
hsvc, err := a.reconcileHeadlessService(ctx, logger, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconcile headless service: %w", err)
|
||||
}
|
||||
|
||||
tags := a.defaultTags
|
||||
if tstr, ok := svc.Annotations[AnnotationTags]; ok {
|
||||
tags = strings.Split(tstr, ",")
|
||||
}
|
||||
secretName, err := a.createOrGetSecret(ctx, logger, svc, hsvc, tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create or get API key secret: %w", err)
|
||||
}
|
||||
_, err = a.reconcileSTS(ctx, logger, svc, hsvc, secretName, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconcile statefulset: %w", err)
|
||||
}
|
||||
|
||||
if !a.hasLoadBalancerClass(svc) {
|
||||
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, tsHost, err := a.getDeviceInfo(ctx, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device ID: %w", err)
|
||||
}
|
||||
if tsHost == "" {
|
||||
logger.Debugf("no Tailscale hostname known yet, waiting for proxy pod to finish auth")
|
||||
// No hostname yet. Wait for the proxy pod to auth.
|
||||
svc.Status.LoadBalancer.Ingress = nil
|
||||
if err := a.Status().Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to update service status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debugf("setting ingress hostname to %q", tsHost)
|
||||
svc.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: tsHost,
|
||||
},
|
||||
}
|
||||
if err := a.Status().Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to update service status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
|
||||
// Headless services can't be exposed, since there is no ClusterIP to
|
||||
// forward to.
|
||||
if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.hasLoadBalancerClass(svc) || a.hasAnnotation(svc)
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
|
||||
return svc != nil &&
|
||||
svc.Spec.Type == corev1.ServiceTypeLoadBalancer &&
|
||||
svc.Spec.LoadBalancerClass != nil &&
|
||||
*svc.Spec.LoadBalancerClass == "tailscale"
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) hasAnnotation(svc *corev1.Service) bool {
|
||||
return svc != nil &&
|
||||
svc.Annotations[AnnotationExpose] == "true"
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (*corev1.Service, error) {
|
||||
hsvc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: "ts-" + svc.Name + "-",
|
||||
Namespace: a.operatorNamespace,
|
||||
Labels: childResourceLabels(svc),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "None",
|
||||
Selector: map[string]string{
|
||||
"app": string(svc.UID),
|
||||
},
|
||||
},
|
||||
}
|
||||
logger.Debugf("reconciling headless service for StatefulSet")
|
||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, svc, hsvc *corev1.Service, tags []string) (string, error) {
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
// Hardcode a -0 suffix so that in future, if we support
|
||||
// multiple StatefulSet replicas, we can provision -N for
|
||||
// those.
|
||||
Name: hsvc.Name + "-0",
|
||||
Namespace: a.operatorNamespace,
|
||||
Labels: childResourceLabels(svc),
|
||||
},
|
||||
}
|
||||
if err := a.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil {
|
||||
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
|
||||
return secret.Name, nil
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Secret doesn't exist yet, create one. Initially it contains
|
||||
// only the Tailscale authkey, but once Tailscale starts it'll
|
||||
// also store the daemon state.
|
||||
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, childResourceLabels(svc))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sts != nil {
|
||||
// StatefulSet exists, so we have already created the secret.
|
||||
// If the secret is missing, they should delete the StatefulSet.
|
||||
logger.Errorf("Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet.", sts.GetNamespace(), sts.GetName())
|
||||
return "", nil
|
||||
}
|
||||
// Create API Key secret which is going to be used by the statefulset
|
||||
// to authenticate with Tailscale.
|
||||
logger.Debugf("creating authkey for new tailscale proxy")
|
||||
authKey, err := a.newAuthKey(ctx, tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret.StringData = map[string]string{
|
||||
"authkey": authKey,
|
||||
}
|
||||
if err := a.Create(ctx, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret.Name, nil
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) getDeviceInfo(ctx context.Context, svc *corev1.Service) (id, hostname string, err error) {
|
||||
sec, err := getSingleObject[corev1.Secret](ctx, a.Client, a.operatorNamespace, childResourceLabels(svc))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
id = string(sec.Data["device_id"])
|
||||
if id == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
// Kubernetes chokes on well-formed FQDNs with the trailing dot, so we have
|
||||
// to remove it.
|
||||
hostname = strings.TrimSuffix(string(sec.Data["device_fqdn"]), ".")
|
||||
if hostname == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
return id, hostname, nil
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) newAuthKey(ctx context.Context, tags []string) (string, error) {
|
||||
caps := tailscale.KeyCapabilities{
|
||||
Devices: tailscale.KeyDeviceCapabilities{
|
||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
||||
Reusable: false,
|
||||
Preauthorized: true,
|
||||
Tags: tags,
|
||||
},
|
||||
},
|
||||
}
|
||||
key, _, err := a.tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
//go:embed manifests/proxy.yaml
|
||||
var proxyYaml []byte
|
||||
|
||||
func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, parentSvc, headlessSvc *corev1.Service, authKeySecret, hostname string) (*appsv1.StatefulSet, error) {
|
||||
var ss appsv1.StatefulSet
|
||||
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
|
||||
}
|
||||
container := &ss.Spec.Template.Spec.Containers[0]
|
||||
container.Image = a.proxyImage
|
||||
container.Env = append(container.Env,
|
||||
corev1.EnvVar{
|
||||
Name: "TS_DEST_IP",
|
||||
Value: parentSvc.Spec.ClusterIP,
|
||||
},
|
||||
corev1.EnvVar{
|
||||
Name: "TS_KUBE_SECRET",
|
||||
Value: authKeySecret,
|
||||
},
|
||||
corev1.EnvVar{
|
||||
Name: "TS_HOSTNAME",
|
||||
Value: hostname,
|
||||
})
|
||||
ss.ObjectMeta = metav1.ObjectMeta{
|
||||
Name: headlessSvc.Name,
|
||||
Namespace: a.operatorNamespace,
|
||||
Labels: childResourceLabels(parentSvc),
|
||||
}
|
||||
ss.Spec.ServiceName = headlessSvc.Name
|
||||
ss.Spec.Selector = &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": string(parentSvc.UID),
|
||||
},
|
||||
}
|
||||
ss.Spec.Template.ObjectMeta.Labels = map[string]string{
|
||||
"app": string(parentSvc.UID),
|
||||
}
|
||||
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
|
||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, &ss, func(s *appsv1.StatefulSet) { s.Spec = ss.Spec })
|
||||
}
|
||||
|
||||
// ptrObject is a type constraint for pointer types that implement
|
||||
// client.Object.
|
||||
type ptrObject[T any] interface {
|
||||
client.Object
|
||||
*T
|
||||
}
|
||||
|
||||
// createOrUpdate adds obj to the k8s cluster, unless the object already exists,
|
||||
// in which case update is called to make changes to it. If update is nil, the
|
||||
// existing object is returned unmodified.
|
||||
//
|
||||
// obj is looked up by its Name and Namespace if Name is set, otherwise it's
|
||||
// looked up by labels.
|
||||
func createOrUpdate[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, obj O, update func(O)) (O, error) {
|
||||
var (
|
||||
existing O
|
||||
err error
|
||||
)
|
||||
if obj.GetName() != "" {
|
||||
existing = new(T)
|
||||
existing.SetName(obj.GetName())
|
||||
existing.SetNamespace(obj.GetNamespace())
|
||||
err = c.Get(ctx, client.ObjectKeyFromObject(obj), existing)
|
||||
} else {
|
||||
existing, err = getSingleObject[T, O](ctx, c, ns, obj.GetLabels())
|
||||
}
|
||||
if err == nil && existing != nil {
|
||||
if update != nil {
|
||||
update(existing)
|
||||
if err := c.Update(ctx, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if err != nil && !apierrors.IsNotFound(err) {
|
||||
return nil, fmt.Errorf("failed to get object: %w", err)
|
||||
}
|
||||
if err := c.Create(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// getSingleObject searches for k8s objects of type T
|
||||
// (e.g. corev1.Service) with the given labels, and returns
|
||||
// it. Returns nil if no objects match the labels, and an error if
|
||||
// more than one object matches.
|
||||
func getSingleObject[T any, O ptrObject[T]](ctx context.Context, c client.Client, ns string, labels map[string]string) (O, error) {
|
||||
ret := O(new(T))
|
||||
kinds, _, err := c.Scheme().ObjectKinds(ret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(kinds) != 1 {
|
||||
// TODO: the runtime package apparently has a "pick the best
|
||||
// GVK" function somewhere that might be good enough?
|
||||
return nil, fmt.Errorf("more than 1 GroupVersionKind for %T", ret)
|
||||
}
|
||||
|
||||
gvk := kinds[0]
|
||||
gvk.Kind += "List"
|
||||
lst := unstructured.UnstructuredList{}
|
||||
lst.SetGroupVersionKind(gvk)
|
||||
if err := c.List(ctx, &lst, client.InNamespace(ns), client.MatchingLabels(labels)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lst.Items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(lst.Items) > 1 {
|
||||
return nil, fmt.Errorf("found multiple matching %T objects", ret)
|
||||
}
|
||||
if err := c.Scheme().Convert(&lst.Items[0], ret, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func defaultEnv(envName, defVal string) string {
|
||||
v := os.Getenv(envName)
|
||||
if v == "" {
|
||||
return defVal
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nameForService(svc *corev1.Service) (string, error) {
|
||||
if h, ok := svc.Annotations[AnnotationHostname]; ok {
|
||||
if err := dnsname.ValidLabel(h); err != nil {
|
||||
return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
return svc.Namespace + "-" + svc.Name, nil
|
||||
}
|
||||
@@ -1,841 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
func TestLoadBalancerClass(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
// that we get to the end.
|
||||
mustUpdate(t, fc, "operator-ns", fullName, func(s *corev1.Secret) {
|
||||
if s.Data == nil {
|
||||
s.Data = map[string][]byte{}
|
||||
}
|
||||
s.Data["device_id"] = []byte("ts-id-1234")
|
||||
s.Data["device_fqdn"] = []byte("tailscale.device.name.")
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
LoadBalancer: corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
|
||||
// Turn the service back into a ClusterIP service, which should make the
|
||||
// operator clean up.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
s.Spec.Type = corev1.ServiceTypeClusterIP
|
||||
s.Spec.LoadBalancerClass = nil
|
||||
// Fake client doesn't automatically delete the LoadBalancer status when
|
||||
// changing away from the LoadBalancer type, we have to do
|
||||
// controller-manager's work by hand.
|
||||
s.Status = corev1.ServiceStatus{}
|
||||
})
|
||||
// synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
|
||||
// didn't create any child resources since this is all faked, so the
|
||||
// deletion goes through immediately.
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
// The deletion triggers another reconcile, to finish the cleanup.
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
}
|
||||
|
||||
func TestAnnotations(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
|
||||
// Turn the service back into a ClusterIP service, which should make the
|
||||
// operator clean up.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
delete(s.ObjectMeta.Annotations, "tailscale.com/expose")
|
||||
})
|
||||
// synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
|
||||
// didn't create any child resources since this is all faked, so the
|
||||
// deletion goes through immediately.
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
// Second time around, the rest of cleanup happens.
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
}
|
||||
|
||||
func TestAnnotationIntoLB(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, since it would have normally happened at
|
||||
// this point and the LoadBalancer is going to expect this.
|
||||
mustUpdate(t, fc, "operator-ns", fullName, func(s *corev1.Secret) {
|
||||
if s.Data == nil {
|
||||
s.Data = map[string][]byte{}
|
||||
}
|
||||
s.Data["device_id"] = []byte("ts-id-1234")
|
||||
s.Data["device_fqdn"] = []byte("tailscale.device.name.")
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
|
||||
// Remove Tailscale's annotation, and at the same time convert the service
|
||||
// into a tailscale LoadBalancer.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
delete(s.ObjectMeta.Annotations, "tailscale.com/expose")
|
||||
s.Spec.Type = corev1.ServiceTypeLoadBalancer
|
||||
s.Spec.LoadBalancerClass = ptr.To("tailscale")
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
// None of the proxy machinery should have changed...
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
|
||||
// ... but the service should have a LoadBalancer status.
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
LoadBalancer: corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
}
|
||||
|
||||
func TestLBIntoAnnotation(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
// that we get to the end.
|
||||
mustUpdate(t, fc, "operator-ns", fullName, func(s *corev1.Secret) {
|
||||
if s.Data == nil {
|
||||
s.Data = map[string][]byte{}
|
||||
}
|
||||
s.Data["device_id"] = []byte("ts-id-1234")
|
||||
s.Data["device_fqdn"] = []byte("tailscale.device.name.")
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
LoadBalancer: corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
|
||||
// Turn the service back into a ClusterIP service, but also add the
|
||||
// tailscale annotation.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
s.ObjectMeta.Annotations = map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
}
|
||||
s.Spec.Type = corev1.ServiceTypeClusterIP
|
||||
s.Spec.LoadBalancerClass = nil
|
||||
// Fake client doesn't automatically delete the LoadBalancer status when
|
||||
// changing away from the LoadBalancer type, we have to do
|
||||
// controller-manager's work by hand.
|
||||
s.Status = corev1.ServiceStatus{}
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test"))
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
},
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
}
|
||||
|
||||
func TestCustomHostname(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
"tailscale.com/hostname": "reindeer-flotilla",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla"))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
"tailscale.com/hostname": "reindeer-flotilla",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
|
||||
// Turn the service back into a ClusterIP service, which should make the
|
||||
// operator clean up.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
delete(s.ObjectMeta.Annotations, "tailscale.com/expose")
|
||||
})
|
||||
// synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
|
||||
// didn't create any child resources since this is all faked, so the
|
||||
// deletion goes through immediately.
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
// Second time around, the rest of cleanup happens.
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/hostname": "reindeer-flotilla",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
}
|
||||
|
||||
func expectedSecret(name string) *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Secret",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"authkey": "secret-authkey",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedHeadlessService(name string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
GenerateName: "ts-test-",
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app": "1234-UID",
|
||||
},
|
||||
ClusterIP: "None",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedSTS(stsName, secretName, hostname string) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: stsName,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: ptr.To[int32](1),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
ServiceName: stsName,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "busybox",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []v1.Container{
|
||||
{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []v1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_DEST_IP", Value: "10.20.30.40"},
|
||||
{Name: "TS_KUBE_SECRET", Value: secretName},
|
||||
{Name: "TS_HOSTNAME", Value: hostname},
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Add: []corev1.Capability{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findGenName(t *testing.T, client client.Client, ns, name string) (full, noSuffix string) {
|
||||
t.Helper()
|
||||
labels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: name,
|
||||
LabelParentNamespace: ns,
|
||||
LabelParentType: "svc",
|
||||
}
|
||||
s, err := getSingleObject[corev1.Secret](context.Background(), client, "operator-ns", labels)
|
||||
if err != nil {
|
||||
t.Fatalf("finding secret for %q: %v", name, err)
|
||||
}
|
||||
return s.GetName(), strings.TrimSuffix(s.GetName(), "-0")
|
||||
}
|
||||
|
||||
func mustCreate(t *testing.T, client client.Client, obj client.Object) {
|
||||
t.Helper()
|
||||
if err := client.Create(context.Background(), obj); err != nil {
|
||||
t.Fatalf("creating %q: %v", obj.GetName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUpdate[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); err != nil {
|
||||
t.Fatalf("getting %q: %v", name, err)
|
||||
}
|
||||
update(obj)
|
||||
if err := client.Update(context.Background(), obj); err != nil {
|
||||
t.Fatalf("updating %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O) {
|
||||
t.Helper()
|
||||
got := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: want.GetName(),
|
||||
Namespace: want.GetNamespace(),
|
||||
}, got); err != nil {
|
||||
t.Fatalf("getting %q: %v", want.GetName(), err)
|
||||
}
|
||||
// The resource version changes eagerly whenever the operator does even a
|
||||
// no-op update. Asserting a specific value leads to overly brittle tests,
|
||||
// so just remove it from both got and want.
|
||||
got.SetResourceVersion("")
|
||||
want.SetResourceVersion("")
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("unexpected object (-got +want):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func expectMissing[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); !apierrors.IsNotFound(err) {
|
||||
t.Fatalf("object %s/%s unexpectedly present, wanted missing", ns, name)
|
||||
}
|
||||
}
|
||||
|
||||
func expectReconciled(t *testing.T, sr *ServiceReconciler, ns, name string) {
|
||||
t.Helper()
|
||||
req := reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
},
|
||||
}
|
||||
res, err := sr.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: unexpected error: %v", err)
|
||||
}
|
||||
if res.Requeue {
|
||||
t.Fatalf("unexpected immediate requeue")
|
||||
}
|
||||
if res.RequeueAfter != 0 {
|
||||
t.Fatalf("unexpected timed requeue (%v)", res.RequeueAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func expectRequeue(t *testing.T, sr *ServiceReconciler, ns, name string) {
|
||||
t.Helper()
|
||||
req := reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
},
|
||||
}
|
||||
res, err := sr.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: unexpected error: %v", err)
|
||||
}
|
||||
if res.Requeue {
|
||||
t.Fatalf("unexpected immediate requeue")
|
||||
}
|
||||
if res.RequeueAfter == 0 {
|
||||
t.Fatalf("expected timed requeue, got success")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.keyRequests = append(c.keyRequests, caps)
|
||||
k := &tailscale.Key{
|
||||
ID: "key",
|
||||
Created: time.Now(),
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
Capabilities: caps,
|
||||
}
|
||||
return "secret-authkey", k, nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) DeleteDevice(ctx context.Context, deviceID string) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.deleted = append(c.deleted, deviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) KeyRequests() []tailscale.KeyCapabilities {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
return c.keyRequests
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) Deleted() []string {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
return c.deleted
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
type whoIsKey struct{}
|
||||
|
||||
// authProxy is an http.Handler that authenticates requests using the Tailscale
|
||||
// LocalAPI and then proxies them to the Kubernetes API.
|
||||
type authProxy struct {
|
||||
logf logger.Logf
|
||||
lc *tailscale.LocalClient
|
||||
rp *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
h.logf("failed to authenticate caller: %v", err)
|
||||
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
|
||||
h.rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripper, logf logger.Logf) {
|
||||
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
if err != nil {
|
||||
log.Fatalf("runAuthProxy: failed to parse URL %v", err)
|
||||
}
|
||||
ap := &authProxy{
|
||||
logf: logf,
|
||||
lc: lc,
|
||||
rp: &httputil.ReverseProxy{
|
||||
Director: func(r *http.Request) {
|
||||
// Replace the request with the user's identity.
|
||||
who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
|
||||
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
|
||||
|
||||
// Remove all authentication headers.
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("Impersonate-Group")
|
||||
r.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Header {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
r.Header.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the URL with the Kubernetes APIServer.
|
||||
r.URL.Scheme = u.Scheme
|
||||
r.URL.Host = u.Host
|
||||
},
|
||||
Transport: rt,
|
||||
},
|
||||
}
|
||||
if err := http.Serve(tls.NewListener(ls, &tls.Config{
|
||||
GetCertificate: lc.GetCertificate,
|
||||
}), ap); err != nil {
|
||||
log.Fatalf("runAuthProxy: failed to serve %v", err)
|
||||
}
|
||||
}
|
||||
173
cmd/microproxy/microproxy.go
Normal file
173
cmd/microproxy/microproxy.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// microproxy proxies incoming HTTPS connections to another
|
||||
// destination. Instead of managing its own TLS certificates, it
|
||||
// borrows issued certificates and keys from an autocert directory.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/logpolicy"
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", ":4430", "server address")
|
||||
certdir = flag.String("certdir", "", "directory to borrow LetsEncrypt certificates from")
|
||||
hostname = flag.String("hostname", "", "hostname to serve")
|
||||
logCollection = flag.String("logcollection", "", "If non-empty, logtail collection to log to")
|
||||
nodeExporter = flag.String("node-exporter", "http://localhost:9100", "URL of the local prometheus node exporter")
|
||||
goVarsURL = flag.String("go-vars-url", "http://localhost:8383/debug/vars", "URL of a local Go server's /debug/vars endpoint")
|
||||
insecure = flag.Bool("insecure", false, "serve over http, for development")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *logCollection != "" {
|
||||
logpolicy.New(*logCollection)
|
||||
}
|
||||
|
||||
ne, err := url.Parse(*nodeExporter)
|
||||
if err != nil {
|
||||
log.Fatalf("Couldn't parse URL %q: %v", *nodeExporter, err)
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(ne)
|
||||
proxy.FlushInterval = time.Second
|
||||
|
||||
if _, err = url.Parse(*goVarsURL); err != nil {
|
||||
log.Fatalf("Couldn't parse URL %q: %v", *goVarsURL, err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux) // registers /debug/*
|
||||
mux.Handle("/metrics", tsweb.Protected(proxy))
|
||||
mux.Handle("/varz", tsweb.Protected(tsweb.StdHandler(&goVarsHandler{*goVarsURL}, tsweb.HandlerOptions{
|
||||
Quiet200s: true,
|
||||
Logf: log.Printf,
|
||||
})))
|
||||
|
||||
ch := &certHolder{
|
||||
hostname: *hostname,
|
||||
path: filepath.Join(*certdir, *hostname),
|
||||
}
|
||||
|
||||
httpsrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
if !*insecure {
|
||||
httpsrv.TLSConfig = &tls.Config{GetCertificate: ch.GetCertificate}
|
||||
err = httpsrv.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
err = httpsrv.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type goVarsHandler struct {
|
||||
url string
|
||||
}
|
||||
|
||||
func promPrint(w io.Writer, prefix string, obj map[string]interface{}) {
|
||||
for k, i := range obj {
|
||||
if prefix != "" {
|
||||
k = prefix + "_" + k
|
||||
}
|
||||
switch v := i.(type) {
|
||||
case map[string]interface{}:
|
||||
promPrint(w, k, v)
|
||||
case float64:
|
||||
const saveConfigReject = "control_save_config_rejected_"
|
||||
const saveConfig = "control_save_config_"
|
||||
switch {
|
||||
case strings.HasPrefix(k, saveConfigReject):
|
||||
fmt.Fprintf(w, "control_save_config_rejected{reason=%q} %f\n", k[len(saveConfigReject):], v)
|
||||
case strings.HasPrefix(k, saveConfig):
|
||||
fmt.Fprintf(w, "control_save_config{reason=%q} %f\n", k[len(saveConfig):], v)
|
||||
default:
|
||||
fmt.Fprintf(w, "%s %f\n", k, v)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(w, "# Skipping key %q, unhandled type %T\n", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *goVarsHandler) ServeHTTPReturn(w http.ResponseWriter, r *http.Request) error {
|
||||
resp, err := http.Get(h.url)
|
||||
if err != nil {
|
||||
return tsweb.Error(http.StatusInternalServerError, "fetch failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var mon map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mon); err != nil {
|
||||
return tsweb.Error(http.StatusInternalServerError, "fetch failed", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
promPrint(w, "", mon)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certHolder loads and caches a TLS certificate from disk, reloading
|
||||
// it every hour.
|
||||
type certHolder struct {
|
||||
hostname string // only hostname allowed in SNI
|
||||
path string // path of certificate+key combined PEM file
|
||||
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate // cached parsed cert+key
|
||||
loaded time.Time
|
||||
}
|
||||
|
||||
func (c *certHolder) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if ch.ServerName != c.hostname {
|
||||
return nil, fmt.Errorf("wrong client SNI %q", ch.ServerName)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if time.Since(c.loaded) > time.Hour {
|
||||
if err := c.loadLocked(); err != nil {
|
||||
log.Printf("Reloading cert %q: %v", c.path, err)
|
||||
// continue anyway, we might be able to serve off the stale cert.
|
||||
}
|
||||
}
|
||||
return c.cert, nil
|
||||
}
|
||||
|
||||
// load reloads the TLS certificate and key from disk. Caller must
|
||||
// hold mu.
|
||||
func (c *certHolder) loadLocked() error {
|
||||
bs, err := ioutil.ReadFile(c.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q: %v", c.path, err)
|
||||
}
|
||||
cert, err := tls.X509KeyPair(bs, bs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing %q: %v", c.path, err)
|
||||
}
|
||||
|
||||
c.cert = &cert
|
||||
c.loaded = time.Now()
|
||||
return nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The mkmanifest command is a simple helper utility to create a '.syso' file
|
||||
// that contains a Windows manifest file.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/tc-hib/winres"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 4 {
|
||||
log.Fatalf("usage: %s arch manifest.xml output.syso", os.Args[0])
|
||||
}
|
||||
|
||||
arch := winres.Arch(os.Args[1])
|
||||
switch arch {
|
||||
case winres.ArchAMD64, winres.ArchARM64, winres.ArchI386, winres.ArchARM:
|
||||
default:
|
||||
log.Fatalf("unsupported arch: %s", arch)
|
||||
}
|
||||
|
||||
manifest, err := os.ReadFile(os.Args[2])
|
||||
if err != nil {
|
||||
log.Fatalf("error reading manifest file %q: %v", os.Args[2], err)
|
||||
}
|
||||
|
||||
out := os.Args[3]
|
||||
|
||||
// Start by creating an empty resource set
|
||||
rs := winres.ResourceSet{}
|
||||
|
||||
// Add resources
|
||||
rs.Set(winres.RT_MANIFEST, winres.ID(1), 0, manifest)
|
||||
|
||||
// Compile to a COFF object file
|
||||
f, err := os.Create(out)
|
||||
if err != nil {
|
||||
log.Fatalf("error creating output file %q: %v", out, err)
|
||||
}
|
||||
if err := rs.WriteObject(f, arch); err != nil {
|
||||
log.Fatalf("error writing object: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
log.Fatalf("error writing output file %q: %v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// mkpkg builds the Tailscale rpm and deb packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/goreleaser/nfpm"
|
||||
_ "github.com/goreleaser/nfpm/deb"
|
||||
_ "github.com/goreleaser/nfpm/rpm"
|
||||
"github.com/pborman/getopt"
|
||||
)
|
||||
|
||||
// parseFiles parses a comma-separated list of colon-separated pairs
|
||||
@@ -43,22 +44,19 @@ func parseEmptyDirs(s string) []string {
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "", "output file to write")
|
||||
name := flag.String("name", "tailscale", "package name")
|
||||
description := flag.String("description", "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", "package description")
|
||||
goarch := flag.String("arch", "amd64", "GOARCH this package is for")
|
||||
pkgType := flag.String("type", "deb", "type of package to build (deb or rpm)")
|
||||
files := flag.String("files", "", "comma-separated list of files in src:dst form")
|
||||
configFiles := flag.String("configs", "", "like --files, but for files marked as user-editable config files")
|
||||
emptyDirs := flag.String("emptydirs", "", "comma-separated list of empty directories")
|
||||
version := flag.String("version", "0.0.0", "version of the package")
|
||||
postinst := flag.String("postinst", "", "debian postinst script path")
|
||||
prerm := flag.String("prerm", "", "debian prerm script path")
|
||||
postrm := flag.String("postrm", "", "debian postrm script path")
|
||||
replaces := flag.String("replaces", "", "package which this package replaces, if any")
|
||||
depends := flag.String("depends", "", "comma-separated list of packages this package depends on")
|
||||
recommends := flag.String("recommends", "", "comma-separated list of packages this package recommends")
|
||||
flag.Parse()
|
||||
out := getopt.StringLong("out", 'o', "", "output file to write")
|
||||
goarch := getopt.StringLong("arch", 'a', "amd64", "GOARCH this package is for")
|
||||
pkgType := getopt.StringLong("type", 't', "deb", "type of package to build (deb or rpm)")
|
||||
files := getopt.StringLong("files", 'F', "", "comma-separated list of files in src:dst form")
|
||||
configFiles := getopt.StringLong("configs", 'C', "", "like --files, but for files marked as user-editable config files")
|
||||
emptyDirs := getopt.StringLong("emptydirs", 'E', "", "comma-separated list of empty directories")
|
||||
version := getopt.StringLong("version", 0, "0.0.0", "version of the package")
|
||||
postinst := getopt.StringLong("postinst", 0, "", "debian postinst script path")
|
||||
prerm := getopt.StringLong("prerm", 0, "", "debian prerm script path")
|
||||
postrm := getopt.StringLong("postrm", 0, "", "debian postrm script path")
|
||||
replaces := getopt.StringLong("replaces", 0, "", "package which this package replaces, if any")
|
||||
depends := getopt.StringLong("depends", 0, "", "comma-separated list of packages this package depends on")
|
||||
getopt.Parse()
|
||||
|
||||
filesMap, err := parseFiles(*files)
|
||||
if err != nil {
|
||||
@@ -70,12 +68,12 @@ func main() {
|
||||
}
|
||||
emptyDirList := parseEmptyDirs(*emptyDirs)
|
||||
info := nfpm.WithDefaults(&nfpm.Info{
|
||||
Name: *name,
|
||||
Name: "tailscale",
|
||||
Arch: *goarch,
|
||||
Platform: "linux",
|
||||
Version: *version,
|
||||
Maintainer: "Tailscale Inc <info@tailscale.com>",
|
||||
Description: *description,
|
||||
Description: "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO",
|
||||
Homepage: "https://www.tailscale.com",
|
||||
License: "MIT",
|
||||
Overridables: nfpm.Overridables{
|
||||
@@ -93,9 +91,6 @@ func main() {
|
||||
if len(*depends) != 0 {
|
||||
info.Overridables.Depends = strings.Split(*depends, ",")
|
||||
}
|
||||
if len(*recommends) != 0 {
|
||||
info.Overridables.Recommends = strings.Split(*recommends, ",")
|
||||
}
|
||||
if *replaces != "" {
|
||||
info.Overridables.Replaces = []string{*replaces}
|
||||
info.Overridables.Conflicts = []string{*replaces}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# nardump
|
||||
|
||||
nardump is like nix-store --dump, but in Go, writing a NAR file (tar-like,
|
||||
but focused on being reproducible) to stdout or to a hash with the --sri flag.
|
||||
|
||||
It lets us calculate the Nix sha256 in shell.nix without the person running
|
||||
git-pull-oss.sh having Nix available.
|
||||
@@ -1,184 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// nardump is like nix-store --dump, but in Go, writing a NAR
|
||||
// file (tar-like, but focused on being reproducible) to stdout
|
||||
// or to a hash with the --sri flag.
|
||||
//
|
||||
// It lets us calculate a Nix sha256 without the person running
|
||||
// git-pull-oss.sh having Nix available.
|
||||
package main
|
||||
|
||||
// For the format, see:
|
||||
// See https://gist.github.com/jbeda/5c79d2b1434f0018d693
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var sri = flag.Bool("sri", false, "print SRI")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
log.Fatal("usage: nardump <dir>")
|
||||
}
|
||||
arg := flag.Arg(0)
|
||||
if err := os.Chdir(arg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if *sri {
|
||||
hash := sha256.New()
|
||||
if err := writeNAR(hash, os.DirFS(".")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("sha256-%s\n", base64.StdEncoding.EncodeToString(hash.Sum(nil)))
|
||||
return
|
||||
}
|
||||
bw := bufio.NewWriter(os.Stdout)
|
||||
if err := writeNAR(bw, os.DirFS(".")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
bw.Flush()
|
||||
}
|
||||
|
||||
// writeNARError is a sentinel panic type that's recovered by writeNAR
|
||||
// and converted into the wrapped error.
|
||||
type writeNARError struct{ err error }
|
||||
|
||||
// narWriter writes NAR files.
|
||||
type narWriter struct {
|
||||
w io.Writer
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
// writeNAR writes a NAR file to w from the root of fs.
|
||||
func writeNAR(w io.Writer, fs fs.FS) (err error) {
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
if we, ok := e.(writeNARError); ok {
|
||||
err = we.err
|
||||
return
|
||||
}
|
||||
panic(e)
|
||||
}
|
||||
}()
|
||||
nw := &narWriter{w: w, fs: fs}
|
||||
nw.str("nix-archive-1")
|
||||
return nw.writeDir(".")
|
||||
}
|
||||
|
||||
func (nw *narWriter) writeDir(dirPath string) error {
|
||||
ents, err := fs.ReadDir(nw.fs, dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(ents, func(i, j int) bool {
|
||||
return ents[i].Name() < ents[j].Name()
|
||||
})
|
||||
nw.str("(")
|
||||
nw.str("type")
|
||||
nw.str("directory")
|
||||
for _, ent := range ents {
|
||||
nw.str("entry")
|
||||
nw.str("(")
|
||||
nw.str("name")
|
||||
nw.str(ent.Name())
|
||||
nw.str("node")
|
||||
mode := ent.Type()
|
||||
sub := path.Join(dirPath, ent.Name())
|
||||
var err error
|
||||
switch {
|
||||
case mode.IsRegular():
|
||||
err = nw.writeRegular(sub)
|
||||
case mode.IsDir():
|
||||
err = nw.writeDir(sub)
|
||||
default:
|
||||
// TODO(bradfitz): symlink, but requires fighting io/fs a bit
|
||||
// to get at Readlink or the osFS via fs. But for now
|
||||
// we don't need symlinks because they're not in Go's archive.
|
||||
return fmt.Errorf("unsupported file type %v at %q", sub, mode)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nw.str(")")
|
||||
}
|
||||
nw.str(")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (nw *narWriter) writeRegular(path string) error {
|
||||
nw.str("(")
|
||||
nw.str("type")
|
||||
nw.str("regular")
|
||||
fi, err := fs.Stat(nw.fs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi.Mode()&0111 != 0 {
|
||||
nw.str("executable")
|
||||
nw.str("")
|
||||
}
|
||||
contents, err := fs.ReadFile(nw.fs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nw.str("contents")
|
||||
if err := writeBytes(nw.w, contents); err != nil {
|
||||
return err
|
||||
}
|
||||
nw.str(")")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (nw *narWriter) str(s string) {
|
||||
if err := writeString(nw.w, s); err != nil {
|
||||
panic(writeNARError{err})
|
||||
}
|
||||
}
|
||||
|
||||
func writeString(w io.Writer, s string) error {
|
||||
var buf [8]byte
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(len(s)))
|
||||
if _, err := w.Write(buf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, s); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePad(w, len(s))
|
||||
}
|
||||
|
||||
func writeBytes(w io.Writer, b []byte) error {
|
||||
var buf [8]byte
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(len(b)))
|
||||
if _, err := w.Write(buf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePad(w, len(b))
|
||||
}
|
||||
|
||||
func writePad(w io.Writer, n int) error {
|
||||
pad := n % 8
|
||||
if pad == 0 {
|
||||
return nil
|
||||
}
|
||||
var zeroes [8]byte
|
||||
_, err := w.Write(zeroes[:8-pad])
|
||||
return err
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// netlogfmt parses a stream of JSON log messages from stdin and
|
||||
// formats the network traffic logs produced by "tailscale.com/wgengine/netlog"
|
||||
// according to the schema in "tailscale.com/types/netlogtype.Message"
|
||||
// in a more humanly readable format.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// $ cat netlog.json | go run tailscale.com/cmd/netlogfmt
|
||||
// =========================================================================================
|
||||
// NodeID: n123456CNTRL
|
||||
// Logged: 2022-10-13T20:23:10.165Z
|
||||
// Window: 2022-10-13T20:23:09.644Z (5s)
|
||||
// --------------------------------------------------- Tx[P/s] Tx[B/s] Rx[P/s] Rx[B/s]
|
||||
// VirtualTraffic: 16.80 1.64Ki 11.20 1.03Ki
|
||||
// TCP: 100.109.51.95:22 -> 100.85.80.41:42912 16.00 1.59Ki 10.40 1008.84
|
||||
// TCP: 100.109.51.95:21291 -> 100.107.177.2:53133 0.40 27.60 0.40 24.20
|
||||
// TCP: 100.109.51.95:21291 -> 100.107.177.2:53134 0.40 23.40 0.40 24.20
|
||||
// PhysicalTraffic: 16.80 2.32Ki 11.20 1.48Ki
|
||||
// 100.85.80.41 -> 192.168.0.101:41641 16.00 2.23Ki 10.40 1.40Ki
|
||||
// 100.107.177.2 -> 192.168.0.100:41641 0.80 83.20 0.80 83.20
|
||||
// =========================================================================================
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dsnet/try"
|
||||
jsonv2 "github.com/go-json-experiment/json"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/logtail"
|
||||
"tailscale.com/types/netlogtype"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
var (
|
||||
resolveNames = flag.Bool("resolve-names", false, "convert tailscale IP addresses to hostnames; must also specify --api-key and --tailnet-id")
|
||||
apiKey = flag.String("api-key", "", "API key to query the Tailscale API with; see https://login.tailscale.com/admin/settings/keys")
|
||||
tailnetName = flag.String("tailnet-name", "", "tailnet domain name to lookup devices in; see https://login.tailscale.com/admin/settings/general")
|
||||
)
|
||||
|
||||
var namesByAddr map[netip.Addr]string
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *resolveNames {
|
||||
namesByAddr = mustMakeNamesByAddr()
|
||||
}
|
||||
|
||||
// The logic handles a stream of arbitrary JSON.
|
||||
// So long as a JSON object seems like a network log message,
|
||||
// then this will unmarshal and print it.
|
||||
if err := processStream(os.Stdin); err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
log.Fatalf("processStream: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func processStream(r io.Reader) (err error) {
|
||||
defer try.Handle(&err)
|
||||
dec := jsonv2.NewDecoder(os.Stdin)
|
||||
for {
|
||||
processValue(dec)
|
||||
}
|
||||
}
|
||||
|
||||
func processValue(dec *jsonv2.Decoder) {
|
||||
switch dec.PeekKind() {
|
||||
case '[':
|
||||
processArray(dec)
|
||||
case '{':
|
||||
processObject(dec)
|
||||
default:
|
||||
try.E(dec.SkipValue())
|
||||
}
|
||||
}
|
||||
|
||||
func processArray(dec *jsonv2.Decoder) {
|
||||
try.E1(dec.ReadToken()) // parse '['
|
||||
for dec.PeekKind() != ']' {
|
||||
processValue(dec)
|
||||
}
|
||||
try.E1(dec.ReadToken()) // parse ']'
|
||||
}
|
||||
|
||||
func processObject(dec *jsonv2.Decoder) {
|
||||
var hasTraffic bool
|
||||
var rawMsg []byte
|
||||
try.E1(dec.ReadToken()) // parse '{'
|
||||
for dec.PeekKind() != '}' {
|
||||
// Capture any members that could belong to a network log message.
|
||||
switch name := try.E1(dec.ReadToken()); name.String() {
|
||||
case "virtualTraffic", "subnetTraffic", "exitTraffic", "physicalTraffic":
|
||||
hasTraffic = true
|
||||
fallthrough
|
||||
case "logtail", "nodeId", "logged", "start", "end":
|
||||
if len(rawMsg) == 0 {
|
||||
rawMsg = append(rawMsg, '{')
|
||||
} else {
|
||||
rawMsg = append(rawMsg[:len(rawMsg)-1], ',')
|
||||
}
|
||||
rawMsg = append(append(append(rawMsg, '"'), name.String()...), '"')
|
||||
rawMsg = append(rawMsg, ':')
|
||||
rawMsg = append(rawMsg, try.E1(dec.ReadValue())...)
|
||||
rawMsg = append(rawMsg, '}')
|
||||
default:
|
||||
processValue(dec)
|
||||
}
|
||||
}
|
||||
try.E1(dec.ReadToken()) // parse '}'
|
||||
|
||||
// If this appears to be a network log message, then unmarshal and print it.
|
||||
if hasTraffic {
|
||||
var msg message
|
||||
try.E(jsonv2.Unmarshal(rawMsg, &msg))
|
||||
printMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Logtail struct {
|
||||
ID logtail.PublicID `json:"id"`
|
||||
Logged time.Time `json:"server_time"`
|
||||
} `json:"logtail"`
|
||||
Logged time.Time `json:"logged"`
|
||||
netlogtype.Message
|
||||
}
|
||||
|
||||
func printMessage(msg message) {
|
||||
// Construct a table of network traffic per connection.
|
||||
rows := [][7]string{{3: "Tx[P/s]", 4: "Tx[B/s]", 5: "Rx[P/s]", 6: "Rx[B/s]"}}
|
||||
duration := msg.End.Sub(msg.Start)
|
||||
addRows := func(heading string, traffic []netlogtype.ConnectionCounts) {
|
||||
if len(traffic) == 0 {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) bool {
|
||||
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
|
||||
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
|
||||
return nx > ny
|
||||
})
|
||||
var sum netlogtype.Counts
|
||||
for _, cc := range traffic {
|
||||
sum = sum.Add(cc.Counts)
|
||||
}
|
||||
rows = append(rows, [7]string{
|
||||
0: heading + ":",
|
||||
3: formatSI(float64(sum.TxPackets) / duration.Seconds()),
|
||||
4: formatIEC(float64(sum.TxBytes) / duration.Seconds()),
|
||||
5: formatSI(float64(sum.RxPackets) / duration.Seconds()),
|
||||
6: formatIEC(float64(sum.RxBytes) / duration.Seconds()),
|
||||
})
|
||||
if len(traffic) == 1 && traffic[0].Connection.IsZero() {
|
||||
return // this is already a summary counts
|
||||
}
|
||||
formatAddrPort := func(a netip.AddrPort) string {
|
||||
if !a.IsValid() {
|
||||
return ""
|
||||
}
|
||||
if name, ok := namesByAddr[a.Addr()]; ok {
|
||||
if a.Port() == 0 {
|
||||
return name
|
||||
}
|
||||
return name + ":" + strconv.Itoa(int(a.Port()))
|
||||
}
|
||||
if a.Port() == 0 {
|
||||
return a.Addr().String()
|
||||
}
|
||||
return a.String()
|
||||
}
|
||||
for _, cc := range traffic {
|
||||
row := [7]string{
|
||||
0: " ",
|
||||
1: formatAddrPort(cc.Src),
|
||||
2: formatAddrPort(cc.Dst),
|
||||
3: formatSI(float64(cc.TxPackets) / duration.Seconds()),
|
||||
4: formatIEC(float64(cc.TxBytes) / duration.Seconds()),
|
||||
5: formatSI(float64(cc.RxPackets) / duration.Seconds()),
|
||||
6: formatIEC(float64(cc.RxBytes) / duration.Seconds()),
|
||||
}
|
||||
if cc.Proto > 0 {
|
||||
row[0] += cc.Proto.String() + ":"
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
addRows("VirtualTraffic", msg.VirtualTraffic)
|
||||
addRows("SubnetTraffic", msg.SubnetTraffic)
|
||||
addRows("ExitTraffic", msg.ExitTraffic)
|
||||
addRows("PhysicalTraffic", msg.PhysicalTraffic)
|
||||
|
||||
// Compute the maximum width of each field.
|
||||
var maxWidths [7]int
|
||||
for _, row := range rows {
|
||||
for i, col := range row {
|
||||
if maxWidths[i] < len(col) && !(i == 0 && !strings.HasPrefix(col, " ")) {
|
||||
maxWidths[i] = len(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
var maxSum int
|
||||
for _, n := range maxWidths {
|
||||
maxSum += n
|
||||
}
|
||||
|
||||
// Output a table of network traffic per connection.
|
||||
line := make([]byte, 0, maxSum+len(" ")+len(" -> ")+4*len(" "))
|
||||
line = appendRepeatByte(line, '=', cap(line))
|
||||
fmt.Println(string(line))
|
||||
if !msg.Logtail.ID.IsZero() {
|
||||
fmt.Printf("LogID: %s\n", msg.Logtail.ID)
|
||||
}
|
||||
if msg.NodeID != "" {
|
||||
fmt.Printf("NodeID: %s\n", msg.NodeID)
|
||||
}
|
||||
formatTime := func(t time.Time) string {
|
||||
return t.In(time.Local).Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
switch {
|
||||
case !msg.Logged.IsZero():
|
||||
fmt.Printf("Logged: %s\n", formatTime(msg.Logged))
|
||||
case !msg.Logtail.Logged.IsZero():
|
||||
fmt.Printf("Logged: %s\n", formatTime(msg.Logtail.Logged))
|
||||
}
|
||||
fmt.Printf("Window: %s (%0.3fs)\n", formatTime(msg.Start), duration.Seconds())
|
||||
for i, row := range rows {
|
||||
line = line[:0]
|
||||
isHeading := !strings.HasPrefix(row[0], " ")
|
||||
for j, col := range row {
|
||||
if isHeading && j == 0 {
|
||||
col = "" // headings will be printed later
|
||||
}
|
||||
switch j {
|
||||
case 0, 2: // left justified
|
||||
line = append(line, col...)
|
||||
line = appendRepeatByte(line, ' ', maxWidths[j]-len(col))
|
||||
case 1, 3, 4, 5, 6: // right justified
|
||||
line = appendRepeatByte(line, ' ', maxWidths[j]-len(col))
|
||||
line = append(line, col...)
|
||||
}
|
||||
switch j {
|
||||
case 0:
|
||||
line = append(line, " "...)
|
||||
case 1:
|
||||
if row[1] == "" && row[2] == "" {
|
||||
line = append(line, " "...)
|
||||
} else {
|
||||
line = append(line, " -> "...)
|
||||
}
|
||||
case 2, 3, 4, 5:
|
||||
line = append(line, " "...)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case i == 0: // print dashed-line table heading
|
||||
line = appendRepeatByte(line[:0], '-', maxWidths[0]+len(" ")+maxWidths[1]+len(" -> ")+maxWidths[2])[:cap(line)]
|
||||
case isHeading:
|
||||
copy(line[:], row[0])
|
||||
}
|
||||
fmt.Println(string(line))
|
||||
}
|
||||
}
|
||||
|
||||
func mustMakeNamesByAddr() map[netip.Addr]string {
|
||||
switch {
|
||||
case *apiKey == "":
|
||||
log.Fatalf("--api-key must be specified with --resolve-names")
|
||||
case *tailnetName == "":
|
||||
log.Fatalf("--tailnet must be specified with --resolve-names")
|
||||
}
|
||||
|
||||
// Query the Tailscale API for a list of devices in the tailnet.
|
||||
const apiURL = "https://api.tailscale.com/api/v2"
|
||||
req := must.Get(http.NewRequest("GET", apiURL+"/tailnet/"+*tailnetName+"/devices", nil))
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(*apiKey+":")))
|
||||
resp := must.Get(http.DefaultClient.Do(req))
|
||||
defer resp.Body.Close()
|
||||
b := must.Get(io.ReadAll(resp.Body))
|
||||
if resp.StatusCode != 200 {
|
||||
log.Fatalf("http: %v: %s", http.StatusText(resp.StatusCode), b)
|
||||
}
|
||||
|
||||
// Unmarshal the API response.
|
||||
var m struct {
|
||||
Devices []struct {
|
||||
Name string `json:"name"`
|
||||
Addrs []netip.Addr `json:"addresses"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
must.Do(json.Unmarshal(b, &m))
|
||||
|
||||
// Construct a unique mapping of Tailscale IP addresses to hostnames.
|
||||
// For brevity, we start with the first segment of the name and
|
||||
// use more segments until we find the shortest prefix that is unique
|
||||
// for all names in the tailnet.
|
||||
seen := make(map[string]bool)
|
||||
namesByAddr := make(map[netip.Addr]string)
|
||||
retry:
|
||||
for i := 0; i < 10; i++ {
|
||||
maps.Clear(seen)
|
||||
maps.Clear(namesByAddr)
|
||||
for _, d := range m.Devices {
|
||||
name := fieldPrefix(d.Name, i)
|
||||
if seen[name] {
|
||||
continue retry
|
||||
}
|
||||
seen[name] = true
|
||||
for _, a := range d.Addrs {
|
||||
namesByAddr[a] = name
|
||||
}
|
||||
}
|
||||
return namesByAddr
|
||||
}
|
||||
panic("unable to produce unique mapping of address to names")
|
||||
}
|
||||
|
||||
// fieldPrefix returns the first n number of dot-separated segments.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fieldPrefix("foo.bar.baz", 0) returns ""
|
||||
// fieldPrefix("foo.bar.baz", 1) returns "foo"
|
||||
// fieldPrefix("foo.bar.baz", 2) returns "foo.bar"
|
||||
// fieldPrefix("foo.bar.baz", 3) returns "foo.bar.baz"
|
||||
// fieldPrefix("foo.bar.baz", 4) returns "foo.bar.baz"
|
||||
func fieldPrefix(s string, n int) string {
|
||||
s0 := s
|
||||
for i := 0; i < n && len(s) > 0; i++ {
|
||||
if j := strings.IndexByte(s, '.'); j >= 0 {
|
||||
s = s[j+1:]
|
||||
} else {
|
||||
s = ""
|
||||
}
|
||||
}
|
||||
return strings.TrimSuffix(s0[:len(s0)-len(s)], ".")
|
||||
}
|
||||
|
||||
func appendRepeatByte(b []byte, c byte, n int) []byte {
|
||||
for i := 0; i < n; i++ {
|
||||
b = append(b, c)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func formatSI(n float64) string {
|
||||
switch n := math.Abs(n); {
|
||||
case n < 1e3:
|
||||
return fmt.Sprintf("%0.2f ", n/(1e0))
|
||||
case n < 1e6:
|
||||
return fmt.Sprintf("%0.2fk", n/(1e3))
|
||||
case n < 1e9:
|
||||
return fmt.Sprintf("%0.2fM", n/(1e6))
|
||||
default:
|
||||
return fmt.Sprintf("%0.2fG", n/(1e9))
|
||||
}
|
||||
}
|
||||
|
||||
func formatIEC(n float64) string {
|
||||
switch n := math.Abs(n); {
|
||||
case n < 1<<10:
|
||||
return fmt.Sprintf("%0.2f ", n/(1<<0))
|
||||
case n < 1<<20:
|
||||
return fmt.Sprintf("%0.2fKi", n/(1<<10))
|
||||
case n < 1<<30:
|
||||
return fmt.Sprintf("%0.2fMi", n/(1<<20))
|
||||
default:
|
||||
return fmt.Sprintf("%0.2fGi", n/(1<<30))
|
||||
}
|
||||
}
|
||||
4
cmd/nginx-auth/.gitignore
vendored
4
cmd/nginx-auth/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
nga.sock
|
||||
*.deb
|
||||
*.rpm
|
||||
tailscale.nginx-auth
|
||||
@@ -1,161 +0,0 @@
|
||||
# nginx-auth
|
||||
|
||||
[](https://tailscale.com/kb/1167/release-stages/#experimental)
|
||||
|
||||
This is a tool that allows users to use Tailscale Whois authentication with
|
||||
NGINX as a reverse proxy. This allows users that already have a bunch of
|
||||
services hosted on an internal NGINX server to point those domains to the
|
||||
Tailscale IP of the NGINX server and then seamlessly use Tailscale for
|
||||
authentication.
|
||||
|
||||
Many thanks to [@zrail](https://twitter.com/zrail/status/1511788463586222087) on
|
||||
Twitter for introducing the basic idea and offering some sample code. This
|
||||
program is based on that sample code with security enhancements. Namely:
|
||||
|
||||
* This listens over a UNIX socket instead of a TCP socket, to prevent
|
||||
leakage to the network
|
||||
* This uses systemd socket activation so that systemd owns the socket
|
||||
and can then lock down the service to the bare minimum required to do
|
||||
its job without having to worry about dropping permissions
|
||||
* This provides additional information in HTTP response headers that can
|
||||
be useful for integrating with various services
|
||||
|
||||
## Configuration
|
||||
|
||||
In order to protect a service with this tool, do the following in the respective
|
||||
`server` block:
|
||||
|
||||
Create an authentication location with the `internal` flag set:
|
||||
|
||||
```nginx
|
||||
location /auth {
|
||||
internal;
|
||||
|
||||
proxy_pass http://unix:/run/tailscale.nginx-auth.sock;
|
||||
proxy_pass_request_body off;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Remote-Addr $remote_addr;
|
||||
proxy_set_header Remote-Port $remote_port;
|
||||
proxy_set_header Original-URI $request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
Then add the following to the `location /` block:
|
||||
|
||||
```
|
||||
auth_request /auth;
|
||||
auth_request_set $auth_user $upstream_http_tailscale_user;
|
||||
auth_request_set $auth_name $upstream_http_tailscale_name;
|
||||
auth_request_set $auth_login $upstream_http_tailscale_login;
|
||||
auth_request_set $auth_tailnet $upstream_http_tailscale_tailnet;
|
||||
auth_request_set $auth_profile_picture $upstream_http_tailscale_profile_picture;
|
||||
|
||||
proxy_set_header X-Webauth-User "$auth_user";
|
||||
proxy_set_header X-Webauth-Name "$auth_name";
|
||||
proxy_set_header X-Webauth-Login "$auth_login";
|
||||
proxy_set_header X-Webauth-Tailnet "$auth_tailnet";
|
||||
proxy_set_header X-Webauth-Profile-Picture "$auth_profile_picture";
|
||||
```
|
||||
|
||||
When this configuration is used with a Go HTTP handler such as this:
|
||||
|
||||
```go
|
||||
http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
|
||||
e := json.NewEncoder(w)
|
||||
e.SetIndent("", " ")
|
||||
e.Encode(r.Header)
|
||||
})
|
||||
```
|
||||
|
||||
You will get output like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"Accept": [
|
||||
"*/*"
|
||||
],
|
||||
"Connection": [
|
||||
"upgrade"
|
||||
],
|
||||
"User-Agent": [
|
||||
"curl/7.82.0"
|
||||
],
|
||||
"X-Webauth-Login": [
|
||||
"Xe"
|
||||
],
|
||||
"X-Webauth-Name": [
|
||||
"Xe Iaso"
|
||||
],
|
||||
"X-Webauth-Profile-Picture": [
|
||||
"https://avatars.githubusercontent.com/u/529003?v=4"
|
||||
],
|
||||
"X-Webauth-Tailnet": [
|
||||
"cetacean.org.github"
|
||||
]
|
||||
"X-Webauth-User": [
|
||||
"Xe@github"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Headers
|
||||
|
||||
The authentication service provides the following headers to decorate your
|
||||
proxied requests:
|
||||
|
||||
| Header | Example Value | Description |
|
||||
| :------ | :-------------- | :---------- |
|
||||
| `Tailscale-User` | `azurediamond@hunter2.net` | The Tailscale username the remote machine is logged in as in user@host form |
|
||||
| `Tailscale-Login` | `azurediamond` | The user portion of the Tailscale username the remote machine is logged in as |
|
||||
| `Tailscale-Name` | `Azure Diamond` | The "real name" of the Tailscale user the machine is logged in as |
|
||||
| `Tailscale-Profile-Picture` | `https://i.kym-cdn.com/photos/images/newsfeed/001/065/963/ae0.png` | The profile picture provided by the Identity Provider your tailnet uses |
|
||||
| `Tailscale-Tailnet` | `hunter2.net` | The tailnet name |
|
||||
|
||||
Most of the time you can set `X-Webauth-User` to the contents of the
|
||||
`Tailscale-User` header, but some services may not accept a username with an `@`
|
||||
symbol in it. If this is the case, set `X-Webauth-User` to the `Tailscale-Login`
|
||||
header.
|
||||
|
||||
The `Tailscale-Tailnet` header can help you identify which tailnet the session
|
||||
is coming from. If you are using node sharing, this can help you make sure that
|
||||
you aren't giving administrative access to people outside your tailnet.
|
||||
|
||||
### Allow Requests From Only One Tailnet
|
||||
|
||||
If you want to prevent node sharing from allowing users to access a service, add
|
||||
the `Expected-Tailnet` header to your auth request:
|
||||
|
||||
```nginx
|
||||
location /auth {
|
||||
# ...
|
||||
proxy_set_header Expected-Tailnet "tailnet012345.ts.net";
|
||||
}
|
||||
```
|
||||
|
||||
If a user from a different tailnet tries to use that service, this will return a
|
||||
generic "forbidden" error page:
|
||||
|
||||
```html
|
||||
<html>
|
||||
<head><title>403 Forbidden</title></head>
|
||||
<body>
|
||||
<center><h1>403 Forbidden</h1></center>
|
||||
<hr><center>nginx/1.18.0 (Ubuntu)</center>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
You can get the tailnet name from [the admin panel](https://login.tailscale.com/admin/dns).
|
||||
|
||||
## Building
|
||||
|
||||
Install `cmd/mkpkg`:
|
||||
|
||||
```
|
||||
cd .. && go install ./mkpkg
|
||||
```
|
||||
|
||||
Then run `./mkdeb.sh`. It will emit a `.deb` and `.rpm` package for amd64
|
||||
machines (Linux uname flag: `x86_64`). You can add these to your deployment
|
||||
methods as you see fit.
|
||||
@@ -1,14 +0,0 @@
|
||||
if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then
|
||||
deb-systemd-helper unmask 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
if deb-systemd-helper --quiet was-enabled 'tailscale.nginx-auth.socket'; then
|
||||
deb-systemd-helper enable 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
else
|
||||
deb-systemd-helper update-state 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
fi
|
||||
|
||||
if systemctl is-active tailscale.nginx-auth.socket >/dev/null; then
|
||||
systemctl --system daemon-reload >/dev/null || true
|
||||
deb-systemd-invoke stop 'tailscale.nginx-auth.service' >/dev/null || true
|
||||
deb-systemd-invoke restart 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
fi
|
||||
fi
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ -d /run/systemd/system ] ; then
|
||||
systemctl --system daemon-reload >/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -x "/usr/bin/deb-systemd-helper" ]; then
|
||||
if [ "$1" = "remove" ]; then
|
||||
deb-systemd-helper mask 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
deb-systemd-helper mask 'tailscale.nginx-auth.service' >/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "$1" = "purge" ]; then
|
||||
deb-systemd-helper purge 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
deb-systemd-helper unmask 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
deb-systemd-helper purge 'tailscale.nginx-auth.service' >/dev/null || true
|
||||
deb-systemd-helper unmask 'tailscale.nginx-auth.service' >/dev/null || true
|
||||
fi
|
||||
fi
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "remove" ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
deb-systemd-invoke stop 'tailscale.nginx-auth.service' >/dev/null || true
|
||||
deb-systemd-invoke stop 'tailscale.nginx-auth.socket' >/dev/null || true
|
||||
fi
|
||||
fi
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -o tailscale.nginx-auth .
|
||||
|
||||
VERSION=0.1.2
|
||||
|
||||
mkpkg \
|
||||
--out=tailscale-nginx-auth-${VERSION}-amd64.deb \
|
||||
--name=tailscale-nginx-auth \
|
||||
--version=${VERSION} \
|
||||
--type=deb \
|
||||
--arch=amd64 \
|
||||
--postinst=deb/postinst.sh \
|
||||
--postrm=deb/postrm.sh \
|
||||
--prerm=deb/prerm.sh \
|
||||
--description="Tailscale NGINX authentication protocol handler" \
|
||||
--files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service,./README.md:/usr/share/tailscale/nginx-auth/README.md
|
||||
|
||||
mkpkg \
|
||||
--out=tailscale-nginx-auth-${VERSION}-amd64.rpm \
|
||||
--name=tailscale-nginx-auth \
|
||||
--version=${VERSION} \
|
||||
--type=rpm \
|
||||
--arch=amd64 \
|
||||
--postinst=rpm/postinst.sh \
|
||||
--postrm=rpm/postrm.sh \
|
||||
--prerm=rpm/prerm.sh \
|
||||
--description="Tailscale NGINX authentication protocol handler" \
|
||||
--files=./tailscale.nginx-auth:/usr/sbin/tailscale.nginx-auth,./tailscale.nginx-auth.socket:/lib/systemd/system/tailscale.nginx-auth.socket,./tailscale.nginx-auth.service:/lib/systemd/system/tailscale.nginx-auth.service,./README.md:/usr/share/tailscale/nginx-auth/README.md
|
||||
@@ -1,128 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
// Command nginx-auth is a tool that allows users to use Tailscale Whois
|
||||
// authentication with NGINX as a reverse proxy. This allows users that
|
||||
// already have a bunch of services hosted on an internal NGINX server
|
||||
// to point those domains to the Tailscale IP of the NGINX server and
|
||||
// then seamlessly use Tailscale for authentication.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-systemd/activation"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
var (
|
||||
sockPath = flag.String("sockpath", "", "the filesystem path for the unix socket this service exposes")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
remoteHost := r.Header.Get("Remote-Addr")
|
||||
remotePort := r.Header.Get("Remote-Port")
|
||||
if remoteHost == "" || remotePort == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
log.Println("set Remote-Addr to $remote_addr and Remote-Port to $remote_port in your nginx config")
|
||||
return
|
||||
}
|
||||
|
||||
remoteAddrStr := net.JoinHostPort(remoteHost, remotePort)
|
||||
remoteAddr, err := netip.ParseAddrPort(remoteAddrStr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
log.Printf("remote address and port are not valid: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := tailscale.WhoIs(r.Context(), remoteAddr.String())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
log.Printf("can't look up %s: %v", remoteAddr, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(info.Node.Tags) != 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
log.Printf("node %s is tagged", info.Node.Hostinfo.Hostname())
|
||||
return
|
||||
}
|
||||
|
||||
// tailnet of connected node. When accessing shared nodes, this
|
||||
// will be empty because the tailnet of the sharee is not exposed.
|
||||
var tailnet string
|
||||
|
||||
if !info.Node.Hostinfo.ShareeNode() {
|
||||
var ok bool
|
||||
_, tailnet, ok = strings.Cut(info.Node.Name, info.Node.ComputedName+".")
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
log.Printf("can't extract tailnet name from hostname %q", info.Node.Name)
|
||||
return
|
||||
}
|
||||
tailnet = strings.TrimSuffix(tailnet, ".beta.tailscale.net")
|
||||
}
|
||||
|
||||
if expectedTailnet := r.Header.Get("Expected-Tailnet"); expectedTailnet != "" && expectedTailnet != tailnet {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
log.Printf("user is part of tailnet %s, wanted: %s", tailnet, url.QueryEscape(expectedTailnet))
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Tailscale-Login", strings.Split(info.UserProfile.LoginName, "@")[0])
|
||||
h.Set("Tailscale-User", info.UserProfile.LoginName)
|
||||
h.Set("Tailscale-Name", info.UserProfile.DisplayName)
|
||||
h.Set("Tailscale-Profile-Picture", info.UserProfile.ProfilePicURL)
|
||||
h.Set("Tailscale-Tailnet", tailnet)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if *sockPath != "" {
|
||||
_ = os.Remove(*sockPath) // ignore error, this file may not already exist
|
||||
ln, err := net.Listen("unix", *sockPath)
|
||||
if err != nil {
|
||||
log.Fatalf("can't listen on %s: %v", *sockPath, err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
log.Printf("listening on %s", *sockPath)
|
||||
log.Fatal(http.Serve(ln, mux))
|
||||
}
|
||||
|
||||
listeners, err := activation.Listeners()
|
||||
if err != nil {
|
||||
log.Fatalf("no sockets passed to this service with systemd: %v", err)
|
||||
}
|
||||
|
||||
// NOTE(Xe): normally you'd want to make a waitgroup here and then register
|
||||
// each listener with it. In this case I want this to blow up horribly if
|
||||
// any of the listeners stop working. systemd will restart it due to the
|
||||
// socket activation at play.
|
||||
//
|
||||
// TL;DR: Let it crash, it will come back
|
||||
for _, ln := range listeners {
|
||||
go func(ln net.Listener) {
|
||||
log.Printf("listening on %s", ln.Addr())
|
||||
log.Fatal(http.Serve(ln, mux))
|
||||
}(ln)
|
||||
}
|
||||
|
||||
for {
|
||||
select {}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# $1 == 0 for uninstallation.
|
||||
# $1 == 1 for removing old package during upgrade.
|
||||
|
||||
systemctl daemon-reload >/dev/null 2>&1 || :
|
||||
if [ $1 -ge 1 ] ; then
|
||||
# Package upgrade, not uninstall
|
||||
systemctl stop tailscale.nginx-auth.service >/dev/null 2>&1 || :
|
||||
systemctl try-restart tailscale.nginx-auth.socket >/dev/null 2>&1 || :
|
||||
fi
|
||||
@@ -1,9 +0,0 @@
|
||||
# $1 == 0 for uninstallation.
|
||||
# $1 == 1 for removing old package during upgrade.
|
||||
|
||||
if [ $1 -eq 0 ] ; then
|
||||
# Package removal, not upgrade
|
||||
systemctl --no-reload disable tailscale.nginx-auth.socket > /dev/null 2>&1 || :
|
||||
systemctl stop tailscale.nginx-auth.socket > /dev/null 2>&1 || :
|
||||
systemctl stop tailscale.nginx-auth.service > /dev/null 2>&1 || :
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user