Compare commits
1 Commits
main
...
walterp-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c92913ef |
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,17 +1,18 @@
|
||||
name: Bug report
|
||||
description: File a bug report. If you need help, contact support instead
|
||||
description: File a bug report
|
||||
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.
|
||||
Please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues).
|
||||
Have an urgent issue? Let us know by emailing us at <support@tailscale.com>.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What is the issue?
|
||||
description: What happened? What did you expect to happen?
|
||||
placeholder: oh no
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -60,13 +61,6 @@ body:
|
||||
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:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -5,4 +5,4 @@ contact_links:
|
||||
about: Contact us for support
|
||||
- name: Troubleshooting
|
||||
url: https://tailscale.com/kb/1023/troubleshooting
|
||||
about: See the troubleshooting guide for help addressing common issues
|
||||
about: Troubleshoot common issues
|
||||
34
.github/workflows/checklocks.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: checklocks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- '.github/workflows/checklocks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checklocks:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- name: Run checklocks vet
|
||||
# TODO(#12625): add more packages as we add annotations
|
||||
run: |-
|
||||
./tool/go vet -vettool=/tmp/checklocks \
|
||||
./envknob \
|
||||
./ipn/store/mem \
|
||||
./net/stun/stuntest \
|
||||
./net/wsconn \
|
||||
./proxymap
|
||||
31
.github/workflows/cifuzz.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: CIFuzz
|
||||
on: [pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Fuzzing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build Fuzzers
|
||||
id: build
|
||||
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
|
||||
with:
|
||||
oss-fuzz-project-name: 'tailscale'
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: Run Fuzzers
|
||||
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: failure() && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
16
.github/workflows/codeql-analysis.yml
vendored
@@ -17,8 +17,6 @@ on:
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
merge_group:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '31 14 * * 5'
|
||||
|
||||
@@ -45,17 +43,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -66,7 +58,7 @@ jobs:
|
||||
# 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@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -80,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
54
.github/workflows/cross-android.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Android-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- name: Android smoke build
|
||||
# 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
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: arm64
|
||||
run: 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
|
||||
|
||||
- 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'
|
||||
62
.github/workflows/cross-darwin.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Darwin-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- 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
|
||||
|
||||
- name: iOS build most
|
||||
env:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
run: go install ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
|
||||
- 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'
|
||||
56
.github/workflows/cross-freebsd.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: FreeBSD-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- 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'
|
||||
56
.github/workflows/cross-openbsd.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: OpenBSD-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- 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'
|
||||
57
.github/workflows/cross-wasm.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Wasm-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- name: Wasm client build
|
||||
env:
|
||||
GOOS: js
|
||||
GOARCH: wasm
|
||||
run: go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
|
||||
|
||||
- name: tsconnect static build
|
||||
# Use our custom Go toolchain, we set build tags (to control binary size)
|
||||
# that depend on it.
|
||||
run: |
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build
|
||||
./tool/go run ./cmd/tsconnect build-pkg
|
||||
|
||||
- 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'
|
||||
56
.github/workflows/cross-windows.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Windows-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- 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'
|
||||
32
.github/workflows/depaware.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: depaware
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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: depaware
|
||||
run: go run github.com/tailscale/depaware --check
|
||||
tailscale.com/cmd/tailscaled
|
||||
tailscale.com/cmd/tailscale
|
||||
tailscale.com/cmd/derper
|
||||
15
.github/workflows/docker-file-build.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: "Dockerfile build"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: "Build Docker image"
|
||||
run: docker build .
|
||||
27
.github/workflows/flakehub-publish-tagged.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: update-flakehub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.*[02468].[0-9]+"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "The existing tag to publish to FlakeHub"
|
||||
type: "string"
|
||||
required: true
|
||||
jobs:
|
||||
flakehub-publish:
|
||||
runs-on: "ubuntu-latest"
|
||||
permissions:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
|
||||
- uses: "DeterminateSystems/nix-installer-action@main"
|
||||
- uses: "DeterminateSystems/flakehub-push@main"
|
||||
with:
|
||||
visibility: "public"
|
||||
tag: "${{ inputs.tag }}"
|
||||
64
.github/workflows/go-licenses.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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@18f90432bedd2afd6a825469ffd38aa24712a91d #v4.1.1
|
||||
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
|
||||
42
.github/workflows/go_generate.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: go generate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: check 'go generate' is clean
|
||||
run: |
|
||||
if [[ "${{github.ref}}" == release-branch/* ]]
|
||||
then
|
||||
pkgs=$(go list ./... | grep -v dnsfallback)
|
||||
else
|
||||
pkgs=$(go list ./... | grep -v dnsfallback)
|
||||
fi
|
||||
go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
35
.github/workflows/go_mod_tidy.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: go mod tidy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: check 'go mod tidy' is clean
|
||||
run: |
|
||||
go mod tidy
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1)
|
||||
39
.github/workflows/golangci-lint.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: golangci-lint
|
||||
on:
|
||||
# For now, only lint pull requests, not the main branches.
|
||||
pull_request:
|
||||
|
||||
# TODO(andrew): enable for main branch after an initial waiting period.
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0
|
||||
with:
|
||||
version: v1.64
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
51
.github/workflows/govulncheck.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: govulncheck
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # 8am EST / 10am PST / 12pm UTC
|
||||
workflow_dispatch: # allow manual trigger for testing
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/govulncheck.yml"
|
||||
|
||||
jobs:
|
||||
source-scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Scan source code for known vulnerabilities
|
||||
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
|
||||
|
||||
- name: Post to slack
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
payload: |
|
||||
{
|
||||
"channel": "C08FGKZCQTW",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Govulncheck failed in ${{ github.repository }}"
|
||||
},
|
||||
"accessory": {
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View results"
|
||||
},
|
||||
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
102
.github/workflows/installer.yml
vendored
@@ -1,102 +0,0 @@
|
||||
name: test installer.sh
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
- .github/workflows/installer.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
- .github/workflows/installer.yml
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
# Don't abort the entire matrix if one element fails.
|
||||
fail-fast: false
|
||||
# Don't start all of these at once, which could saturate Github workers.
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
image:
|
||||
# This is a list of Docker images against which we test our installer.
|
||||
# If you find that some of these no longer exist, please feel free
|
||||
# to remove them from the list.
|
||||
# When adding new images, please only use official ones.
|
||||
- "debian:oldstable-slim"
|
||||
- "debian:stable-slim"
|
||||
- "debian:testing-slim"
|
||||
- "debian:sid-slim"
|
||||
- "ubuntu:20.04"
|
||||
- "ubuntu:22.04"
|
||||
- "ubuntu:24.04"
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
- "parrotsec/core:latest"
|
||||
- "kalilinux/kali-rolling"
|
||||
- "kalilinux/kali-dev"
|
||||
- "oraclelinux:9"
|
||||
- "oraclelinux:8"
|
||||
- "fedora:latest"
|
||||
- "rockylinux:8.7"
|
||||
- "rockylinux:9"
|
||||
- "amazonlinux:latest"
|
||||
- "opensuse/leap:latest"
|
||||
- "opensuse/tumbleweed:latest"
|
||||
- "archlinux:latest"
|
||||
- "alpine:3.21"
|
||||
- "alpine:latest"
|
||||
- "alpine:edge"
|
||||
deps:
|
||||
# Run all images installing curl as a dependency.
|
||||
- curl
|
||||
include:
|
||||
# Check a few images with wget rather than curl.
|
||||
- { image: "debian:oldstable-slim", deps: "wget" }
|
||||
- { image: "debian:sid-slim", deps: "wget" }
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
options: --user root
|
||||
steps:
|
||||
- name: install dependencies (pacman)
|
||||
# Refresh the package databases to ensure that the tailscale package is
|
||||
# defined.
|
||||
run: pacman -Sy
|
||||
if: contains(matrix.image, 'archlinux')
|
||||
- name: install dependencies (yum)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
|
||||
if: |
|
||||
contains(matrix.image, 'centos')
|
||||
|| contains(matrix.image, 'oraclelinux')
|
||||
|| contains(matrix.image, 'fedora')
|
||||
|| contains(matrix.image, 'amazonlinux')
|
||||
- name: install dependencies (zypper)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: zypper --non-interactive install tar gzip ${{ matrix.deps }}
|
||||
if: contains(matrix.image, 'opensuse')
|
||||
- name: install dependencies (apt-get)
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y ${{ matrix.deps }}
|
||||
if: |
|
||||
contains(matrix.image, 'debian')
|
||||
|| contains(matrix.image, 'ubuntu')
|
||||
|| contains(matrix.image, 'elementary')
|
||||
|| contains(matrix.image, 'parrotsec')
|
||||
|| contains(matrix.image, 'kalilinux')
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
# as PID 1, so ignore errors at this step. The real check is the
|
||||
# `tailscale --version` command below.
|
||||
continue-on-error: true
|
||||
- name: check tailscale version
|
||||
run: tailscale --version
|
||||
31
.github/workflows/kubemanifests.yaml
vendored
@@ -1,31 +0,0 @@
|
||||
name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cmd/k8s-operator/**'
|
||||
- 'k8s-operator/**'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
# running
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
testchart:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Build and lint Helm chart
|
||||
run: |
|
||||
eval `./tool/go run ./cmd/mkversion`
|
||||
./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart'
|
||||
./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz"
|
||||
- name: Verify that static manifests are up to date
|
||||
run: |
|
||||
make kube-generate-all
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "Generated files for Tailscale Kubernetes operator are out of date. Please run 'make kube-generate-all' and commit the diff."; exit 1)
|
||||
44
.github/workflows/license.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: license
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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: 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'
|
||||
66
.github/workflows/linux-race.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Linux race
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- name: Basic build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Run tests and benchmarks with -race flag on linux
|
||||
run: go test -race -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: Check that no tracked files in the repo have been modified
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
|
||||
- name: Check that no files have been added to the repo
|
||||
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
|
||||
|
||||
- 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'
|
||||
|
||||
80
.github/workflows/linux.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- name: Basic build
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Build variants
|
||||
run: |
|
||||
go install --tags=ts_include_cli ./cmd/tailscaled
|
||||
go install --tags=ts_omit_aws ./cmd/tailscaled
|
||||
|
||||
- name: Get QEMU
|
||||
run: |
|
||||
# The qemu in Ubuntu 20.04 (Focal) is too old; we need 5.x something
|
||||
# to run Go binaries. 5.2.0 (Debian bullseye) empirically works, and
|
||||
# use this PPA which brings in a modern qemu.
|
||||
sudo add-apt-repository -y ppa:jacob/virtualisation
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y install qemu-user
|
||||
|
||||
- name: Run tests on linux
|
||||
run: go test -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: Check that no tracked files in the repo have been modified
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
|
||||
- name: Check that no files have been added to the repo
|
||||
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
|
||||
|
||||
- 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'
|
||||
|
||||
66
.github/workflows/linux32.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Linux 32-bit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- name: Basic build
|
||||
run: GOARCH=386 go build ./cmd/...
|
||||
|
||||
- name: Run tests on linux
|
||||
run: GOARCH=386 go test -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: Check that no tracked files in the repo have been modified
|
||||
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
|
||||
|
||||
- name: Check that no files have been added to the repo
|
||||
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
|
||||
|
||||
- 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'
|
||||
|
||||
27
.github/workflows/natlab-integrationtest.yml
vendored
@@ -1,27 +0,0 @@
|
||||
# Run some natlab integration tests.
|
||||
# See https://github.com/tailscale/tailscale/issues/13038
|
||||
name: "natlab-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "tstest/integration/nat/nat_test.go"
|
||||
jobs:
|
||||
natlab-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install qemu
|
||||
run: |
|
||||
sudo rm /var/lib/man-db/auto-update
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y remove man-db
|
||||
sudo apt-get install -y qemu-system-x86 qemu-utils
|
||||
- name: Run natlab integration tests
|
||||
run: |
|
||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests
|
||||
23
.github/workflows/ssh-integrationtest.yml
vendored
@@ -1,23 +0,0 @@
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "ssh/**"
|
||||
- "tempfork/gliderlabs/ssh/**"
|
||||
- ".github/workflows/ssh-integrationtest"
|
||||
jobs:
|
||||
ssh-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
make sshintegrationtest
|
||||
112
.github/workflows/static-analysis.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
name: static-analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gofmt:
|
||||
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: Run gofmt (goimports)
|
||||
run: go run golang.org/x/tools/cmd/goimports -d --format-only .
|
||||
- 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'
|
||||
|
||||
vet:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
- 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'
|
||||
|
||||
staticcheck:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, windows, darwin]
|
||||
goarch: [amd64]
|
||||
include:
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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 (${{ matrix.goos }}/${{ matrix.goarch }})"
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: "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'
|
||||
629
.github/workflows/test.yml
vendored
@@ -1,629 +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: false
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
# all PRs on all branches
|
||||
merge_group:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
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:
|
||||
race-root-integration:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/4'
|
||||
- shard: '2/4'
|
||||
- shard: '3/4'
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
|
||||
env:
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- goarch: amd64
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '1/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '2/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '3/3'
|
||||
- goarch: "386" # thanks yaml
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
|
||||
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: |
|
||||
~/.cache/go-build
|
||||
~/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).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
|
||||
- name: build all
|
||||
if: matrix.buildflags == '' # skip on race builder
|
||||
run: ./tool/go build ${{matrix.buildflags}} ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
- name: build variant CLIs
|
||||
if: matrix.buildflags == '' # skip on race builder
|
||||
run: |
|
||||
export TS_USE_TOOLCHAIN=1
|
||||
./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.buildflags == ''
|
||||
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
|
||||
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
- name: bench all
|
||||
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
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
|
||||
- name: Tidy cache
|
||||
shell: bash
|
||||
run: |
|
||||
find $(go env GOCACHE) -type f -mmin +90 -delete
|
||||
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
|
||||
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: |
|
||||
~/.cache/go-build
|
||||
~/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).
|
||||
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-go-2-
|
||||
- name: test
|
||||
run: go run ./cmd/testwrapper ./...
|
||||
- name: bench all
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
# the equals signs cause great confusion.
|
||||
run: go test ./... -bench . -benchtime 1x -run "^$"
|
||||
- name: Tidy cache
|
||||
shell: bash
|
||||
run: |
|
||||
find $(go env GOCACHE) -type f -mmin +90 -delete
|
||||
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
|
||||
|
||||
privileged:
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: golang:latest
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: chown
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
run: ./tool/go test ./util/linuxfw ./derp/xdp
|
||||
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/var/lib/ghrunner/home"
|
||||
TMPDIR: "/tmp"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
run: ./tool/go test -race -exec=true ./...
|
||||
|
||||
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
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: "5"
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: "7"
|
||||
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
|
||||
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: |
|
||||
~/.cache/go-build
|
||||
~/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).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
|
||||
- name: build all
|
||||
run: ./tool/go build ./cmd/...
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: "0"
|
||||
- name: build tests
|
||||
run: ./tool/go test -exec=true ./...
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: "0"
|
||||
- name: Tidy cache
|
||||
shell: bash
|
||||
run: |
|
||||
find $(go env GOCACHE) -type f -mmin +90 -delete
|
||||
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
|
||||
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
|
||||
crossmin: # cross-compile for platforms where we only check cmd/tailscale{,d}
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
# AIX
|
||||
- goos: aix
|
||||
goarch: ppc64
|
||||
# Solaris
|
||||
- goos: solaris
|
||||
goarch: amd64
|
||||
# illumos
|
||||
- goos: illumos
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
|
||||
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: |
|
||||
~/.cache/go-build
|
||||
~/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).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
|
||||
- name: build core
|
||||
run: ./tool/go build ./cmd/tailscale ./cmd/tailscaled
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: "0"
|
||||
- name: Tidy cache
|
||||
shell: bash
|
||||
run: |
|
||||
find $(go env GOCACHE) -type f -mmin +90 -delete
|
||||
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
|
||||
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
# 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/netmon ./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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
|
||||
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: |
|
||||
~/.cache/go-build
|
||||
~/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).
|
||||
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-go-2-
|
||||
- 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
|
||||
- name: Tidy cache
|
||||
shell: bash
|
||||
run: |
|
||||
find $(go env GOCACHE) -type f -mmin +90 -delete
|
||||
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
|
||||
|
||||
tailscale_go: # Subset of tests that depend on our custom Go toolchain.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: test tailscale_go
|
||||
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
|
||||
|
||||
|
||||
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: Set artifacts_path in env (workaround for actions/upload-artifact#176)
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
run: |
|
||||
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ${{ env.artifacts_path }}/out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- 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 --internal
|
||||
|
||||
go_generate:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
|
||||
./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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- 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:
|
||||
if: always()
|
||||
# Any of these jobs failing causes a slack notification.
|
||||
needs:
|
||||
- android
|
||||
- test
|
||||
- windows
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
- tailscale_go
|
||||
- fuzz
|
||||
- depaware
|
||||
- go_generate
|
||||
- go_mod_tidy
|
||||
- licenses
|
||||
- staticcheck
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: notify
|
||||
# Only notify slack for merged commits, not PR failures.
|
||||
#
|
||||
# It may be tempting to move this condition into the job's 'if' block, but
|
||||
# don't: Github only collapses the test list into "everything is OK" if
|
||||
# all jobs succeeded. A skipped job results in the list staying expanded.
|
||||
# By having the job always run, but skipping its only step as needed, we
|
||||
# let the CI output collapse nicely in PRs.
|
||||
if: failure() && github.event_name == 'push'
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
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"
|
||||
}]
|
||||
}
|
||||
|
||||
check_mergeability:
|
||||
if: always()
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- android
|
||||
- test
|
||||
- windows
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
- tailscale_go
|
||||
- fuzz
|
||||
- depaware
|
||||
- go_generate
|
||||
- go_mod_tidy
|
||||
- licenses
|
||||
- staticcheck
|
||||
steps:
|
||||
- name: Decide if change is okay to merge
|
||||
if: github.event_name != 'push'
|
||||
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
30
.github/workflows/tsconnect-pkg-publish.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
50
.github/workflows/update-flake.yml
vendored
@@ -1,50 +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:
|
||||
update-flake:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
committer: Flakes Updater <noreply+flakes-updater@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
|
||||
53
.github/workflows/update-webclient-prebuilt.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: update-webclient-prebuilt
|
||||
|
||||
on:
|
||||
# manually triggered
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-webclient-prebuilt:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Run go get
|
||||
run: |
|
||||
./tool/go version # build gocross if needed using regular GOPROXY
|
||||
GOPROXY=direct ./tool/go get github.com/tailscale/web-client-prebuilt
|
||||
./tool/go mod tidy
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
id: generate-token
|
||||
with:
|
||||
# TODO(will): this should use the code updater app rather than licensing.
|
||||
# It has the same permissions, so not a big deal, but still.
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
committer: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
branch: actions/update-webclient-prebuilt
|
||||
commit-message: "go.mod: update web-client-prebuilt module"
|
||||
title: "go.mod: update web-client-prebuilt module"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
reviewers: ${{ github.triggering_actor }}
|
||||
|
||||
- name: Summary
|
||||
if: ${{ steps.pull-request.outputs.pull-request-number }}
|
||||
run: echo "${{ steps.pull-request.outputs.pull-request-operation}} ${{ steps.pull-request.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
|
||||
50
.github/workflows/vm.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: VM
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ubuntu2004-LTS-cloud-base:
|
||||
runs-on: [ self-hosted, linux, vm ]
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Set GOPATH
|
||||
run: echo "GOPATH=$HOME/go" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run VM tests
|
||||
run: go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
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'
|
||||
40
.github/workflows/webclient.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: webclient
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# For now, only run on requests, not the main branches.
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- "client/web/**"
|
||||
- ".github/workflows/webclient.yml"
|
||||
- "!**.md"
|
||||
# TODO(soniaappasamy): enable for main branch after an initial waiting period.
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
webclient:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install deps
|
||||
run: ./tool/yarn --cwd client/web
|
||||
- name: Run lint
|
||||
run: ./tool/yarn --cwd client/web run --silent lint
|
||||
- name: Run test
|
||||
run: ./tool/yarn --cwd client/web run --silent test
|
||||
- name: Run formatter check
|
||||
run: |
|
||||
./tool/yarn --cwd client/web run --silent format-check || ( \
|
||||
echo "Run this command on your local device to fix the error:" && \
|
||||
echo "" && \
|
||||
echo " ./tool/yarn --cwd client/web format" && \
|
||||
echo "" && exit 1)
|
||||
66
.github/workflows/windows.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike some 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: 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'
|
||||
|
||||
27
.gitignore
vendored
@@ -9,7 +9,6 @@
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
ssh/tailssh/testcontainers/tailscaled
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -23,29 +22,3 @@ ssh/tailssh/testcontainers/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/
|
||||
|
||||
# Support personal project-specific GOPATH
|
||||
.gopath/
|
||||
|
||||
# Ignore nix build result path
|
||||
/result
|
||||
|
||||
# Ignore direnv nix-shell environment cache
|
||||
.direnv/
|
||||
|
||||
# Ignore web client node modules
|
||||
.vite/
|
||||
client/web/node_modules
|
||||
client/web/build/assets
|
||||
|
||||
/gocross
|
||||
/dist
|
||||
|
||||
# Ignore xcode userstate and workspace data
|
||||
*.xcuserstate
|
||||
*.xcworkspacedata
|
||||
/tstest/tailmac/bin
|
||||
/tstest/tailmac/build
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
linters:
|
||||
# Don't enable any linters by default; just the ones that we explicitly
|
||||
# enable in the list below.
|
||||
disable-all: true
|
||||
enable:
|
||||
- bidichk
|
||||
- gofmt
|
||||
- goimports
|
||||
- govet
|
||||
- misspell
|
||||
- revive
|
||||
|
||||
# Configuration for how we run golangci-lint
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
issues:
|
||||
# Excluding configuration per-path, per-linter, per-text and per-source
|
||||
exclude-rules:
|
||||
# These are forks of an upstream package and thus are exempt from stylistic
|
||||
# changes that would make pulling in upstream changes harder.
|
||||
- path: tempfork/.*\.go
|
||||
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
|
||||
- path: util/singleflight/.*\.go
|
||||
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
|
||||
|
||||
# Per-linter settings are contained in this top-level key
|
||||
linters-settings:
|
||||
gofmt:
|
||||
rewrite-rules:
|
||||
- pattern: 'interface{}'
|
||||
replacement: 'any'
|
||||
|
||||
govet:
|
||||
# Matches what we use in corp as of 2023-12-07
|
||||
enable:
|
||||
- asmdecl
|
||||
- assign
|
||||
- atomic
|
||||
- bools
|
||||
- buildtag
|
||||
- cgocall
|
||||
- copylocks
|
||||
- deepequalerrors
|
||||
- errorsas
|
||||
- framepointer
|
||||
- httpresponse
|
||||
- ifaceassert
|
||||
- loopclosure
|
||||
- lostcancel
|
||||
- nilfunc
|
||||
- nilness
|
||||
- printf
|
||||
- reflectvaluecompare
|
||||
- shift
|
||||
- sigchanyzer
|
||||
- sortslice
|
||||
- stdmethods
|
||||
- stringintconv
|
||||
- structtag
|
||||
- testinggoroutine
|
||||
- tests
|
||||
- unmarshal
|
||||
- unreachable
|
||||
- unsafeptr
|
||||
- unusedresult
|
||||
settings:
|
||||
printf:
|
||||
# List of print function names to check (in addition to default)
|
||||
funcs:
|
||||
- github.com/tailscale/tailscale/types/logger.Discard
|
||||
# NOTE(andrew-d): this doesn't currently work because the printf
|
||||
# analyzer doesn't support type declarations
|
||||
#- github.com/tailscale/tailscale/types/logger.Logf
|
||||
|
||||
revive:
|
||||
enable-all-rules: false
|
||||
ignore-generated-header: true
|
||||
rules:
|
||||
- name: atomic
|
||||
- name: context-keys-type
|
||||
- name: defer
|
||||
arguments: [[
|
||||
# Calling 'recover' at the time a defer is registered (i.e. "defer recover()") has no effect.
|
||||
"immediate-recover",
|
||||
# Calling 'recover' outside of a deferred function has no effect
|
||||
"recover",
|
||||
# Returning values from a deferred function has no effect
|
||||
"return",
|
||||
]]
|
||||
- name: duplicated-imports
|
||||
- name: errorf
|
||||
- name: string-of-int
|
||||
- name: time-equal
|
||||
- name: unconditional-recursion
|
||||
- name: useless-break
|
||||
- name: waitgroup-by-value
|
||||
@@ -1 +1 @@
|
||||
3.19
|
||||
3.16
|
||||
@@ -1 +0,0 @@
|
||||
/tailcfg/ @tailscale/control-protocol-owners
|
||||
42
Dockerfile
@@ -1,13 +1,18 @@
|
||||
# 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.
|
||||
|
||||
# Note that this Dockerfile is currently NOT used to build any of the published
|
||||
# Tailscale container images and may have drifted from the image build mechanism
|
||||
# we use.
|
||||
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
|
||||
# and the build script can be found in ./build_docker.sh.
|
||||
############################################################################
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
|
||||
# This Dockerfile includes all the tailscale binaries.
|
||||
#
|
||||
# To build the Dockerfile:
|
||||
@@ -27,7 +32,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.24-alpine AS build-env
|
||||
FROM golang:1.19-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
@@ -42,8 +47,9 @@ RUN go install \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
github.com/coder/websocket \
|
||||
github.com/mdlayher/netlink
|
||||
nhooyr.io/websocket \
|
||||
github.com/mdlayher/netlink \
|
||||
golang.zx2c4.com/wireguard/device
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -57,17 +63,13 @@ 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
|
||||
-X tailscale.com/version.Long=$VERSION_LONG \
|
||||
-X tailscale.com/version.Short=$VERSION_SHORT \
|
||||
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled
|
||||
|
||||
FROM alpine:3.19
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
RUN rm /sbin/iptables && ln -s /sbin/iptables-legacy /sbin/iptables
|
||||
RUN rm /sbin/ip6tables && ln -s /sbin/ip6tables-legacy /sbin/ip6tables
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
# For compat with the previous run.sh, although ideally you should be
|
||||
# using build_docker.sh which sets an entrypoint for the image.
|
||||
RUN mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh
|
||||
COPY --from=build-env /go/src/tailscale/docs/k8s/run.sh /usr/local/bin/
|
||||
|
||||
@@ -1,12 +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.
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates iptables iptables-legacy iproute2 ip6tables iputils
|
||||
# Alpine 3.19 replaces legacy iptables with nftables based implementation. We
|
||||
# can't be certain that all hosts that run Tailscale containers currently
|
||||
# suppport nftables, so link back to legacy for backwards compatibility reasons.
|
||||
# TODO(irbekrm): add some way how to determine if we still run on nodes that
|
||||
# don't support nftables, so that we can eventually remove these symlinks.
|
||||
RUN rm /sbin/iptables && ln -s /sbin/iptables-legacy /sbin/iptables
|
||||
RUN rm /sbin/ip6tables && ln -s /sbin/ip6tables-legacy /sbin/ip6tables
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
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:
|
||||
|
||||
120
Makefile
@@ -1,128 +1,56 @@
|
||||
IMAGE_REPO ?= tailscale/tailscale
|
||||
SYNO_ARCH ?= "x86_64"
|
||||
SYNO_ARCH ?= "amd64"
|
||||
SYNO_DSM ?= "7"
|
||||
TAGS ?= "latest"
|
||||
|
||||
PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
|
||||
usage:
|
||||
echo "See Makefile"
|
||||
|
||||
vet: ## Run go vet
|
||||
vet:
|
||||
./tool/go vet ./...
|
||||
|
||||
tidy: ## Run go mod tidy
|
||||
tidy:
|
||||
./tool/go mod tidy
|
||||
|
||||
lint: ## Run golangci-lint
|
||||
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
||||
|
||||
updatedeps: ## Update depaware deps
|
||||
# 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 --internal \
|
||||
updatedeps:
|
||||
./tool/go run github.com/tailscale/depaware --update \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
# 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 --internal \
|
||||
depaware:
|
||||
./tool/go run github.com/tailscale/depaware --check \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
buildwindows:
|
||||
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
build386: ## Build tailscale CLI for linux/386
|
||||
build386:
|
||||
GOOS=linux GOARCH=386 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildlinuxarm: ## Build tailscale CLI for linux/arm
|
||||
buildlinuxarm:
|
||||
GOOS=linux GOARCH=arm ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildwasm: ## Build tailscale CLI for js/wasm
|
||||
buildwasm:
|
||||
GOOS=js GOARCH=wasm ./tool/go install ./cmd/tsconnect/wasm ./cmd/tailscale/cli
|
||||
|
||||
buildplan9:
|
||||
GOOS=plan9 GOARCH=amd64 ./tool/go install ./cmd/tailscale ./cmd/tailscaled
|
||||
|
||||
buildlinuxloong64: ## Build tailscale CLI for linux/loong64
|
||||
GOOS=linux GOARCH=loong64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
|
||||
buildmultiarchimage: ## Build (and optionally push) multiarch docker image
|
||||
buildmultiarchimage:
|
||||
./build_docker.sh
|
||||
|
||||
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ## Perform basic checks and compilation tests
|
||||
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm
|
||||
|
||||
staticcheck: ## Run staticcheck.io checks
|
||||
staticcheck:
|
||||
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
|
||||
|
||||
kube-generate-all: kube-generate-deepcopy ## Refresh generated files for Tailscale Kubernetes Operator
|
||||
./tool/go generate ./cmd/k8s-operator
|
||||
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}
|
||||
|
||||
# Tailscale operator watches Connector custom resources in a Kubernetes cluster
|
||||
# and caches them locally. Caching is done implicitly by controller-runtime
|
||||
# library (the middleware used by Tailscale operator to create kube control
|
||||
# loops). When a Connector resource is GET/LIST-ed from within our control loop,
|
||||
# the request goes through the cache. To ensure that cache contents don't get
|
||||
# modified by control loops, controller-runtime deep copies the requested
|
||||
# object. In order for this to work, Connector must implement deep copy
|
||||
# functionality so we autogenerate it here.
|
||||
# https://github.com/kubernetes-sigs/controller-runtime/blob/v0.16.3/pkg/cache/internal/cache_reader.go#L86-L89
|
||||
kube-generate-deepcopy: ## Refresh generated deepcopy functionality for Tailscale kube API types
|
||||
./scripts/kube-deepcopy.sh
|
||||
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
|
||||
|
||||
spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version
|
||||
./tool/go run ./cmd/dist build synology/dsm${SYNO_DSM}/${SYNO_ARCH}
|
||||
|
||||
spkall: ## Build synology packages for all architectures and DSM versions
|
||||
./tool/go run ./cmd/dist build synology
|
||||
|
||||
pushspk: spk ## Push and install synology package on ${SYNO_HOST} host
|
||||
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: ## Build and publish tailscale image to location specified by ${REPO}
|
||||
@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="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
|
||||
|
||||
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
|
||||
@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="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-operator ./build_docker.sh
|
||||
|
||||
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
|
||||
@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-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
|
||||
|
||||
.PHONY: sshintegrationtest
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
|
||||
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
|
||||
|
||||
help: ## Show this help
|
||||
@echo "\nSpecify a command. The choices are:\n"
|
||||
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
.PHONY: help
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
35
README.md
@@ -6,41 +6,27 @@ 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 on Linux, Windows and [macOS](https://tailscale.com/kb/1065/macos-variants/), and to varying degrees on FreeBSD, OpenBSD, and Darwin. (The Tailscale iOS and Android apps use this repo's code, but this repo doesn't contain the mobile GUI code.)
|
||||
|
||||
Other [Tailscale repos](https://github.com/orgs/tailscale/repositories) of note:
|
||||
The Android app is at https://github.com/tailscale/tailscale-android
|
||||
|
||||
* 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 Synology package is at https://github.com/tailscale/tailscale-synology
|
||||
|
||||
## 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.23. (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 +43,8 @@ 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 require the latest Go release, currently Go 1.19.
|
||||
|
||||
## Bugs
|
||||
|
||||
Please file any issues about this code or the hosted service on
|
||||
@@ -71,9 +59,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://go.dev/wiki/CommitMessage).
|
||||
|
||||
## About Us
|
||||
|
||||
[Tailscale](https://tailscale.com/) is primarily developed by the
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.83.0
|
||||
1.31.0
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appc implements App Connectors.
|
||||
// An AppConnector provides DNS domain oriented routing of traffic. An App
|
||||
// Connector becomes a DNS server for a peer, authoritative for the set of
|
||||
// configured domains. DNS resolution of the target domain triggers dynamic
|
||||
// publication of routes to ensure that traffic to the domain is routed through
|
||||
// the App Connector.
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/execqueue"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
// rateLogger responds to calls to update by adding a count for the current period and
|
||||
// calling the callback if any previous period has finished since update was last called
|
||||
type rateLogger struct {
|
||||
interval time.Duration
|
||||
start time.Time
|
||||
periodStart time.Time
|
||||
periodCount int64
|
||||
now func() time.Time
|
||||
callback func(int64, time.Time, int64)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
|
||||
millisSince := now.Sub(rl.start).Milliseconds() % rl.interval.Milliseconds()
|
||||
return now.Add(-(time.Duration(millisSince)) * time.Millisecond)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) update(numRoutes int64) {
|
||||
now := rl.now()
|
||||
periodEnd := rl.periodStart.Add(rl.interval)
|
||||
if periodEnd.Before(now) {
|
||||
if rl.periodCount != 0 {
|
||||
rl.callback(rl.periodCount, rl.periodStart, numRoutes)
|
||||
}
|
||||
rl.periodCount = 0
|
||||
rl.periodStart = rl.currentIntervalStart(now)
|
||||
}
|
||||
rl.periodCount++
|
||||
}
|
||||
|
||||
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time, int64)) *rateLogger {
|
||||
nowTime := now()
|
||||
return &rateLogger{
|
||||
callback: callback,
|
||||
now: now,
|
||||
interval: interval,
|
||||
start: nowTime,
|
||||
periodStart: nowTime,
|
||||
}
|
||||
}
|
||||
|
||||
// RouteAdvertiser is an interface that allows the AppConnector to advertise
|
||||
// newly discovered routes that need to be served through the AppConnector.
|
||||
type RouteAdvertiser interface {
|
||||
// AdvertiseRoute adds one or more route advertisements skipping any that
|
||||
// are already advertised.
|
||||
AdvertiseRoute(...netip.Prefix) error
|
||||
|
||||
// UnadvertiseRoute removes any matching route advertisements.
|
||||
UnadvertiseRoute(...netip.Prefix) error
|
||||
}
|
||||
|
||||
var (
|
||||
metricStoreRoutesRateBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000}
|
||||
metricStoreRoutesNBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000, 10000}
|
||||
metricStoreRoutesRate []*clientmetric.Metric
|
||||
metricStoreRoutesN []*clientmetric.Metric
|
||||
)
|
||||
|
||||
func initMetricStoreRoutes() {
|
||||
for _, n := range metricStoreRoutesRateBuckets {
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_rate_%d", n)))
|
||||
}
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter("appc_store_routes_rate_over"))
|
||||
for _, n := range metricStoreRoutesNBuckets {
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_n_routes_%d", n)))
|
||||
}
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter("appc_store_routes_n_routes_over"))
|
||||
}
|
||||
|
||||
func recordMetric(val int64, buckets []int64, metrics []*clientmetric.Metric) {
|
||||
if len(buckets) < 1 {
|
||||
return
|
||||
}
|
||||
// finds the first bucket where val <=, or len(buckets) if none match
|
||||
// for bucket values of 1, 10, 100; 0-1 goes to [0], 2-10 goes to [1], 11-100 goes to [2], 101+ goes to [3]
|
||||
bucket, _ := slices.BinarySearch(buckets, val)
|
||||
metrics[bucket].Add(1)
|
||||
}
|
||||
|
||||
func metricStoreRoutes(rate, nRoutes int64) {
|
||||
if len(metricStoreRoutesRate) == 0 {
|
||||
initMetricStoreRoutes()
|
||||
}
|
||||
recordMetric(rate, metricStoreRoutesRateBuckets, metricStoreRoutesRate)
|
||||
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
|
||||
}
|
||||
|
||||
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
|
||||
// so that we can know, even after a restart, which routes came from ACLs and which were
|
||||
// learned from domains.
|
||||
type RouteInfo struct {
|
||||
// Control is the routes from the 'routes' section of an app connector acl.
|
||||
Control []netip.Prefix `json:",omitempty"`
|
||||
// Domains are the routes discovered by observing DNS lookups for configured domains.
|
||||
Domains map[string][]netip.Addr `json:",omitempty"`
|
||||
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
|
||||
// its result is added to Domains.
|
||||
Wildcards []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// AppConnector is an implementation of an AppConnector that performs
|
||||
// its function as a subsystem inside of a tailscale node. At the control plane
|
||||
// side App Connector routing is configured in terms of domains rather than IP
|
||||
// addresses.
|
||||
// The AppConnectors responsibility inside tailscaled is to apply the routing
|
||||
// and domain configuration as supplied in the map response.
|
||||
// DNS requests for configured domains are observed. If the domains resolve to
|
||||
// routes not yet served by the AppConnector the local node configuration is
|
||||
// updated to advertise the new route.
|
||||
type AppConnector struct {
|
||||
logf logger.Logf
|
||||
routeAdvertiser RouteAdvertiser
|
||||
|
||||
// storeRoutesFunc will be called to persist routes if it is not nil.
|
||||
storeRoutesFunc func(*RouteInfo) error
|
||||
|
||||
// mu guards the fields that follow
|
||||
mu sync.Mutex
|
||||
|
||||
// domains is a map of lower case domain names with no trailing dot, to an
|
||||
// ordered list of resolved IP addresses.
|
||||
domains map[string][]netip.Addr
|
||||
|
||||
// controlRoutes is the list of routes that were last supplied by control.
|
||||
controlRoutes []netip.Prefix
|
||||
|
||||
// wildcards is the list of domain strings that match subdomains.
|
||||
wildcards []string
|
||||
|
||||
// queue provides ordering for update operations
|
||||
queue execqueue.ExecQueue
|
||||
|
||||
writeRateMinute *rateLogger
|
||||
writeRateDay *rateLogger
|
||||
}
|
||||
|
||||
// NewAppConnector creates a new AppConnector.
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
|
||||
ac := &AppConnector{
|
||||
logf: logger.WithPrefix(logf, "appc: "),
|
||||
routeAdvertiser: routeAdvertiser,
|
||||
storeRoutesFunc: storeRoutesFunc,
|
||||
}
|
||||
if routeInfo != nil {
|
||||
ac.domains = routeInfo.Domains
|
||||
ac.wildcards = routeInfo.Wildcards
|
||||
ac.controlRoutes = routeInfo.Control
|
||||
}
|
||||
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
|
||||
metricStoreRoutes(c, l)
|
||||
})
|
||||
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
|
||||
})
|
||||
return ac
|
||||
}
|
||||
|
||||
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
|
||||
// and is storing its discovered routes persistently.
|
||||
func (e *AppConnector) ShouldStoreRoutes() bool {
|
||||
return e.storeRoutesFunc != nil
|
||||
}
|
||||
|
||||
// storeRoutesLocked takes the current state of the AppConnector and persists it
|
||||
func (e *AppConnector) storeRoutesLocked() error {
|
||||
if !e.ShouldStoreRoutes() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// log write rate and write size
|
||||
numRoutes := int64(len(e.controlRoutes))
|
||||
for _, rs := range e.domains {
|
||||
numRoutes += int64(len(rs))
|
||||
}
|
||||
e.writeRateMinute.update(numRoutes)
|
||||
e.writeRateDay.update(numRoutes)
|
||||
|
||||
return e.storeRoutesFunc(&RouteInfo{
|
||||
Control: e.controlRoutes,
|
||||
Domains: e.domains,
|
||||
Wildcards: e.wildcards,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRoutes removes all route state from the AppConnector.
|
||||
func (e *AppConnector) ClearRoutes() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.controlRoutes = nil
|
||||
e.domains = nil
|
||||
e.wildcards = nil
|
||||
return e.storeRoutesLocked()
|
||||
}
|
||||
|
||||
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
|
||||
// given the new domains and routes.
|
||||
func (e *AppConnector) UpdateDomainsAndRoutes(domains []string, routes []netip.Prefix) {
|
||||
e.queue.Add(func() {
|
||||
// Add the new routes first.
|
||||
e.updateRoutes(routes)
|
||||
e.updateDomains(domains)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDomains asynchronously replaces the current set of configured domains
|
||||
// with the supplied set of domains. Domains must not contain a trailing dot,
|
||||
// and should be lower case. If the domain contains a leading '*' label it
|
||||
// matches all subdomains of a domain.
|
||||
func (e *AppConnector) UpdateDomains(domains []string) {
|
||||
e.queue.Add(func() {
|
||||
e.updateDomains(domains)
|
||||
})
|
||||
}
|
||||
|
||||
// Wait waits for the currently scheduled asynchronous configuration changes to
|
||||
// complete.
|
||||
func (e *AppConnector) Wait(ctx context.Context) {
|
||||
e.queue.Wait(ctx)
|
||||
}
|
||||
|
||||
func (e *AppConnector) updateDomains(domains []string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var oldDomains map[string][]netip.Addr
|
||||
oldDomains, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
|
||||
e.wildcards = e.wildcards[:0]
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(d)
|
||||
if len(d) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(d, "*.") {
|
||||
e.wildcards = append(e.wildcards, d[2:])
|
||||
continue
|
||||
}
|
||||
e.domains[d] = oldDomains[d]
|
||||
delete(oldDomains, d)
|
||||
}
|
||||
|
||||
// Ensure that still-live wildcards addresses are preserved as well.
|
||||
for d, addrs := range oldDomains {
|
||||
for _, wc := range e.wildcards {
|
||||
if dnsname.HasSuffix(d, wc) {
|
||||
e.domains[d] = addrs
|
||||
delete(oldDomains, d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything left in oldDomains is a domain we're no longer tracking
|
||||
// and if we are storing route info we can unadvertise the routes
|
||||
if e.ShouldStoreRoutes() {
|
||||
toRemove := []netip.Prefix{}
|
||||
for _, addrs := range oldDomains {
|
||||
for _, a := range addrs {
|
||||
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
}
|
||||
e.queue.Add(func() {
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
e.logf("handling domains: %v and wildcards: %v", slicesx.MapKeys(e.domains), e.wildcards)
|
||||
}
|
||||
|
||||
// updateRoutes merges the supplied routes into the currently configured routes. The routes supplied
|
||||
// by control for UpdateRoutes are supplemental to the routes discovered by DNS resolution, but are
|
||||
// also more often whole ranges. UpdateRoutes will remove any single address routes that are now
|
||||
// covered by new ranges.
|
||||
func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// If there was no change since the last update, no work to do.
|
||||
if slices.Equal(e.controlRoutes, routes) {
|
||||
return
|
||||
}
|
||||
|
||||
var toRemove []netip.Prefix
|
||||
|
||||
// If we're storing routes and know e.controlRoutes is a good
|
||||
// representation of what should be in AdvertisedRoutes we can stop
|
||||
// advertising routes that used to be in e.controlRoutes but are not
|
||||
// in routes.
|
||||
if e.ShouldStoreRoutes() {
|
||||
toRemove = routesWithout(e.controlRoutes, routes)
|
||||
}
|
||||
|
||||
nextRoute:
|
||||
for _, r := range routes {
|
||||
for _, addr := range e.domains {
|
||||
for _, a := range addr {
|
||||
if r.Contains(a) && netip.PrefixFrom(a, a.BitLen()) != r {
|
||||
pfx := netip.PrefixFrom(a, a.BitLen())
|
||||
toRemove = append(toRemove, pfx)
|
||||
continue nextRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.queue.Add(func() {
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
|
||||
e.logf("failed to advertise routes: %v: %v", routes, err)
|
||||
}
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
|
||||
}
|
||||
})
|
||||
|
||||
e.controlRoutes = routes
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
e.logf("failed to store route info: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Domains returns the currently configured domain list.
|
||||
func (e *AppConnector) Domains() views.Slice[string] {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
return views.SliceOf(slicesx.MapKeys(e.domains))
|
||||
}
|
||||
|
||||
// DomainRoutes returns a map of domains to resolved IP
|
||||
// addresses.
|
||||
func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
drCopy := make(map[string][]netip.Addr)
|
||||
for k, v := range e.domains {
|
||||
drCopy[k] = append(drCopy[k], v...)
|
||||
}
|
||||
|
||||
return drCopy
|
||||
}
|
||||
|
||||
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
|
||||
// response is being returned over the PeerAPI. The response is parsed and
|
||||
// matched against the configured domains, if matched the routeAdvertiser is
|
||||
// advised to advertise the discovered route.
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
|
||||
// a CNAME chain back to the original query for flattening. The keys are
|
||||
// CNAME record targets, and the value is the name the record answers, so
|
||||
// for www.example.com CNAME example.com, the map would contain
|
||||
// ["example.com"] = "www.example.com".
|
||||
var cnameChain map[string]string
|
||||
|
||||
// addressRecords is a list of address records found in the response.
|
||||
var addressRecords map[string][]netip.Addr
|
||||
|
||||
for {
|
||||
h, err := p.AnswerHeader()
|
||||
if err == dnsmessage.ErrSectionDone {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.Class != dnsmessage.ClassINET {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
|
||||
}
|
||||
|
||||
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
|
||||
if len(domain) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if h.Type == dnsmessage.TypeCNAME {
|
||||
res, err := p.CNAMEResource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
|
||||
if len(cname) == 0 {
|
||||
continue
|
||||
}
|
||||
mak.Set(&cnameChain, cname, domain)
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr := netip.AddrFrom4(r.A)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr := netip.AddrFrom16(r.AAAA)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for domain, addrs := range addressRecords {
|
||||
domain, isRouted := e.findRoutedDomainLocked(domain, cnameChain)
|
||||
|
||||
// domain and none of the CNAMEs in the chain are routed
|
||||
if !isRouted {
|
||||
continue
|
||||
}
|
||||
|
||||
// advertise each address we have learned for the routed domain, that
|
||||
// was not already known.
|
||||
var toAdvertise []netip.Prefix
|
||||
for _, addr := range addrs {
|
||||
if !e.isAddrKnownLocked(domain, addr) {
|
||||
toAdvertise = append(toAdvertise, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
}
|
||||
|
||||
if len(toAdvertise) > 0 {
|
||||
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// starting from the given domain that resolved to an address, find it, or any
|
||||
// of the domains in the CNAME chain toward resolving it, that are routed
|
||||
// domains, returning the routed domain name and a bool indicating whether a
|
||||
// routed domain was found.
|
||||
// e.mu must be held.
|
||||
func (e *AppConnector) findRoutedDomainLocked(domain string, cnameChain map[string]string) (string, bool) {
|
||||
var isRouted bool
|
||||
for {
|
||||
_, isRouted = e.domains[domain]
|
||||
if isRouted {
|
||||
break
|
||||
}
|
||||
|
||||
// match wildcard domains
|
||||
for _, wc := range e.wildcards {
|
||||
if dnsname.HasSuffix(domain, wc) {
|
||||
e.domains[domain] = nil
|
||||
isRouted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
next, ok := cnameChain[domain]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
domain = next
|
||||
}
|
||||
return domain, isRouted
|
||||
}
|
||||
|
||||
// isAddrKnownLocked returns true if the address is known to be associated with
|
||||
// the given domain. Known domain tables are updated for covered routes to speed
|
||||
// up future matches.
|
||||
// e.mu must be held.
|
||||
func (e *AppConnector) isAddrKnownLocked(domain string, addr netip.Addr) bool {
|
||||
if e.hasDomainAddrLocked(domain, addr) {
|
||||
return true
|
||||
}
|
||||
for _, route := range e.controlRoutes {
|
||||
if route.Contains(addr) {
|
||||
// record the new address associated with the domain for faster matching in subsequent
|
||||
// requests and for diagnostic records.
|
||||
e.addDomainAddrLocked(domain, addr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scheduleAdvertisement schedules an advertisement of the given address
|
||||
// associated with the given domain.
|
||||
func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Prefix) {
|
||||
e.queue.Add(func() {
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
|
||||
e.logf("failed to advertise routes for %s: %v: %v", domain, routes, err)
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for _, route := range routes {
|
||||
if !route.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
addr := route.Addr()
|
||||
if !e.hasDomainAddrLocked(domain, addr) {
|
||||
e.addDomainAddrLocked(domain, addr)
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
}
|
||||
}
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
e.logf("failed to store route info: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// hasDomainAddrLocked returns true if the address has been observed in a
|
||||
// resolution of domain.
|
||||
func (e *AppConnector) hasDomainAddrLocked(domain string, addr netip.Addr) bool {
|
||||
_, ok := slices.BinarySearchFunc(e.domains[domain], addr, compareAddr)
|
||||
return ok
|
||||
}
|
||||
|
||||
// addDomainAddrLocked adds the address to the list of addresses resolved for
|
||||
// domain and ensures the list remains sorted. Does not attempt to deduplicate.
|
||||
func (e *AppConnector) addDomainAddrLocked(domain string, addr netip.Addr) {
|
||||
e.domains[domain] = append(e.domains[domain], addr)
|
||||
slices.SortFunc(e.domains[domain], compareAddr)
|
||||
}
|
||||
|
||||
func compareAddr(l, r netip.Addr) int {
|
||||
return l.Compare(r)
|
||||
}
|
||||
|
||||
// routesWithout returns a without b where a and b
|
||||
// are unsorted slices of netip.Prefix
|
||||
func routesWithout(a, b []netip.Prefix) []netip.Prefix {
|
||||
m := make(map[netip.Prefix]bool, len(b))
|
||||
for _, p := range b {
|
||||
m[p] = true
|
||||
}
|
||||
return slicesx.Filter(make([]netip.Prefix, 0, len(a)), a, func(p netip.Prefix) bool {
|
||||
return !m[p]
|
||||
})
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc/appctest"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
func fakeStoreRoutes(*RouteInfo) error { return nil }
|
||||
|
||||
func TestUpdateDomains(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil)
|
||||
}
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
|
||||
a.Wait(ctx)
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.Wait(ctx)
|
||||
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
a.Wait(ctx)
|
||||
if got, want := slicesx.MapKeys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoutes(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
|
||||
// This route should be collapsed into the range
|
||||
if err := a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
|
||||
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
}
|
||||
|
||||
// This route should not be collapsed or removed
|
||||
if err := a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
|
||||
a.updateRoutes(routes)
|
||||
a.Wait(ctx)
|
||||
|
||||
slices.SortFunc(rc.Routes(), prefixCompare)
|
||||
rc.SetRoutes(slices.Compact(rc.Routes()))
|
||||
slices.SortFunc(routes, prefixCompare)
|
||||
|
||||
// Ensure that the non-matching /32 is preserved, even though it's in the domains table.
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
|
||||
// Ensure that the contained /32 is removed, replaced by the /24.
|
||||
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
|
||||
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
|
||||
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
|
||||
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
|
||||
a.updateRoutes(routes)
|
||||
a.Wait(ctx)
|
||||
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainRoutes(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(context.Background())
|
||||
|
||||
want := map[string][]netip.Addr{
|
||||
"example.com": {netip.MustParseAddr("192.0.0.8")},
|
||||
}
|
||||
|
||||
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
|
||||
a.updateDomains([]string{"example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// matches a routed domain.
|
||||
a.updateDomains([]string{"www.example.com", "example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// even if only found in the middle of the chain
|
||||
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// don't re-advertise routes that have already been advertised
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
|
||||
// don't advertise addresses that are already in a control provided route
|
||||
pfx := netip.MustParsePrefix("192.0.2.0/24")
|
||||
a.updateRoutes([]netip.Prefix{pfx})
|
||||
wantRoutes = append(wantRoutes, pfx)
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
|
||||
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildcardDomains(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
|
||||
t.Errorf("routes: got %v; want %v", got, want)
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if _, ok := a.domains["foo.example.com"]; !ok {
|
||||
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if len(a.wildcards) != 1 {
|
||||
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
|
||||
func dnsResponse(domain, address string) []byte {
|
||||
addr := netip.MustParseAddr(address)
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
|
||||
b.EnableCompression()
|
||||
b.StartAnswers()
|
||||
switch addr.BitLen() {
|
||||
case 32:
|
||||
b.AResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AResource{
|
||||
A: addr.As4(),
|
||||
},
|
||||
)
|
||||
case 128:
|
||||
b.AAAAResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeAAAA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AAAAResource{
|
||||
AAAA: addr.As16(),
|
||||
},
|
||||
)
|
||||
default:
|
||||
panic("invalid address length")
|
||||
}
|
||||
return must.Get(b.Finish())
|
||||
}
|
||||
|
||||
func dnsCNAMEResponse(address string, domains ...string) []byte {
|
||||
addr := netip.MustParseAddr(address)
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
|
||||
b.EnableCompression()
|
||||
b.StartAnswers()
|
||||
|
||||
if len(domains) >= 2 {
|
||||
for i, domain := range domains[:len(domains)-1] {
|
||||
b.CNAMEResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeCNAME,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.CNAMEResource{
|
||||
CNAME: dnsmessage.MustNewName(domains[i+1]),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
domain := domains[len(domains)-1]
|
||||
|
||||
switch addr.BitLen() {
|
||||
case 32:
|
||||
b.AResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AResource{
|
||||
A: addr.As4(),
|
||||
},
|
||||
)
|
||||
case 128:
|
||||
b.AAAAResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeAAAA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AAAAResource{
|
||||
AAAA: addr.As16(),
|
||||
},
|
||||
)
|
||||
default:
|
||||
panic("invalid address length")
|
||||
}
|
||||
return must.Get(b.Finish())
|
||||
}
|
||||
|
||||
func prefixEqual(a, b netip.Prefix) bool {
|
||||
return a == b
|
||||
}
|
||||
|
||||
func prefixCompare(a, b netip.Prefix) int {
|
||||
if a.Addr().Compare(b.Addr()) == 0 {
|
||||
return a.Bits() - b.Bits()
|
||||
}
|
||||
return a.Addr().Compare(b.Addr())
|
||||
}
|
||||
|
||||
func prefixes(in ...string) []netip.Prefix {
|
||||
toRet := make([]netip.Prefix, len(in))
|
||||
for i, s := range in {
|
||||
toRet[i] = netip.MustParsePrefix(s)
|
||||
}
|
||||
return toRet
|
||||
}
|
||||
|
||||
func TestUpdateRouteRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
// nothing has yet been advertised
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32"))
|
||||
a.Wait(ctx)
|
||||
// the routes passed to UpdateDomainsAndRoutes have been advertised
|
||||
assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{})
|
||||
|
||||
// one route the same, one different
|
||||
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32"))
|
||||
a.Wait(ctx)
|
||||
// old behavior: routes are not removed, resulting routes are both old and new
|
||||
// (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication
|
||||
// the real one does)
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior: routes are removed, resulting routes are new only
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.2/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDomainRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
for _, res := range [][]byte{
|
||||
dnsResponse("a.example.com.", "1.2.3.1"),
|
||||
dnsResponse("a.example.com.", "1.2.3.2"),
|
||||
dnsResponse("b.example.com.", "1.2.3.3"),
|
||||
dnsResponse("b.example.com.", "1.2.3.4"),
|
||||
} {
|
||||
if err := a.ObserveDNSResponse(res); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
}
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// old behavior, routes are not removed
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior, routes are removed for b.example.com
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWildcardRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
for _, res := range [][]byte{
|
||||
dnsResponse("a.example.com.", "1.2.3.1"),
|
||||
dnsResponse("a.example.com.", "1.2.3.2"),
|
||||
dnsResponse("1.b.example.com.", "1.2.3.3"),
|
||||
dnsResponse("2.b.example.com.", "1.2.3.4"),
|
||||
} {
|
||||
if err := a.ObserveDNSResponse(res); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
}
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// old behavior, routes are not removed
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior, routes are removed for *.b.example.com
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesWithout(t *testing.T) {
|
||||
assert := func(msg string, got, want []netip.Prefix) {
|
||||
if !slices.Equal(want, got) {
|
||||
t.Errorf("%s: want %v, got %v", msg, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{})
|
||||
assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{})
|
||||
assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32"))
|
||||
assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32"))
|
||||
assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{})
|
||||
assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32"))
|
||||
}
|
||||
|
||||
func TestRateLogger(t *testing.T) {
|
||||
clock := tstest.Clock{}
|
||||
wasCalled := false
|
||||
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
wasCalled = true
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Millisecond)
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Second)
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
|
||||
wasCalled = false
|
||||
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
wasCalled = true
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Minute)
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Hour)
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStoreMetrics(t *testing.T) {
|
||||
metricStoreRoutes(1, 1)
|
||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||
metricStoreRoutes(6, 6) // the 10 buckets value should be 1
|
||||
metricStoreRoutes(10001, 10001) // the over buckets value should be 1
|
||||
wanted := map[string]int64{
|
||||
"appc_store_routes_n_routes_1": 2,
|
||||
"appc_store_routes_rate_1": 2,
|
||||
"appc_store_routes_n_routes_5": 1,
|
||||
"appc_store_routes_rate_5": 1,
|
||||
"appc_store_routes_n_routes_10": 1,
|
||||
"appc_store_routes_rate_10": 1,
|
||||
"appc_store_routes_n_routes_over": 1,
|
||||
"appc_store_routes_rate_over": 1,
|
||||
}
|
||||
for _, x := range clientmetric.Metrics() {
|
||||
if x.Value() != wanted[x.Name()] {
|
||||
t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricBucketsAreSorted(t *testing.T) {
|
||||
if !slices.IsSorted(metricStoreRoutesRateBuckets) {
|
||||
t.Errorf("metricStoreRoutesRateBuckets must be in order")
|
||||
}
|
||||
if !slices.IsSorted(metricStoreRoutesNBuckets) {
|
||||
t.Errorf("metricStoreRoutesNBuckets must be in order")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateRoutesDeadlock is a regression test for a deadlock in
|
||||
// LocalBackend<->AppConnector interaction. When using real LocalBackend as the
|
||||
// routeAdvertiser, calls to Advertise/UnadvertiseRoutes can end up calling
|
||||
// back into AppConnector via authReconfig. If everything is called
|
||||
// synchronously, this results in a deadlock on AppConnector.mu.
|
||||
func TestUpdateRoutesDeadlock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
|
||||
advertiseCalled := new(atomic.Bool)
|
||||
unadvertiseCalled := new(atomic.Bool)
|
||||
rc.AdvertiseCallback = func() {
|
||||
// Call something that requires a.mu to be held.
|
||||
a.DomainRoutes()
|
||||
advertiseCalled.Store(true)
|
||||
}
|
||||
rc.UnadvertiseCallback = func() {
|
||||
// Call something that requires a.mu to be held.
|
||||
a.DomainRoutes()
|
||||
unadvertiseCalled.Store(true)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.Wait(ctx)
|
||||
|
||||
// Trigger rc.AdveriseRoute.
|
||||
a.updateRoutes(
|
||||
[]netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.1/32"),
|
||||
netip.MustParsePrefix("127.0.0.2/32"),
|
||||
},
|
||||
)
|
||||
a.Wait(ctx)
|
||||
// Trigger rc.UnadveriseRoute.
|
||||
a.updateRoutes(
|
||||
[]netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.1/32"),
|
||||
},
|
||||
)
|
||||
a.Wait(ctx)
|
||||
|
||||
if !advertiseCalled.Load() {
|
||||
t.Error("AdvertiseRoute was not called")
|
||||
}
|
||||
if !unadvertiseCalled.Load() {
|
||||
t.Error("UnadvertiseRoute was not called")
|
||||
}
|
||||
|
||||
if want := []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}; !slices.Equal(slices.Compact(rc.Routes()), want) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), want)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appctest contains code to help test App Connectors.
|
||||
package appctest
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// RouteCollector is a test helper that collects the list of routes advertised
|
||||
type RouteCollector struct {
|
||||
// AdvertiseCallback (optional) is called synchronously from
|
||||
// AdvertiseRoute.
|
||||
AdvertiseCallback func()
|
||||
// UnadvertiseCallback (optional) is called synchronously from
|
||||
// UnadvertiseRoute.
|
||||
UnadvertiseCallback func()
|
||||
|
||||
routes []netip.Prefix
|
||||
removedRoutes []netip.Prefix
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) AdvertiseRoute(pfx ...netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx...)
|
||||
if rc.AdvertiseCallback != nil {
|
||||
rc.AdvertiseCallback()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) UnadvertiseRoute(toRemove ...netip.Prefix) error {
|
||||
routes := rc.routes
|
||||
rc.routes = rc.routes[:0]
|
||||
for _, r := range routes {
|
||||
if !slices.Contains(toRemove, r) {
|
||||
rc.routes = append(rc.routes, r)
|
||||
} else {
|
||||
rc.removedRoutes = append(rc.removedRoutes, r)
|
||||
}
|
||||
}
|
||||
if rc.UnadvertiseCallback != nil {
|
||||
rc.UnadvertiseCallback()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovedRoutes returns the list of routes that were removed.
|
||||
func (rc *RouteCollector) RemovedRoutes() []netip.Prefix {
|
||||
return rc.removedRoutes
|
||||
}
|
||||
|
||||
// Routes returns the ordered list of routes that were added, including
|
||||
// possible duplicates.
|
||||
func (rc *RouteCollector) Routes() []netip.Prefix {
|
||||
return rc.routes
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) SetRoutes(routes []netip.Prefix) error {
|
||||
rc.routes = routes
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build tailscale_go
|
||||
|
||||
package tailscaleroot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
tsRev, ok := tailscaleToolchainRev()
|
||||
if !ok {
|
||||
panic("binary built with tailscale_go build tag but failed to read build info or find tailscale.toolchain.rev in build info")
|
||||
}
|
||||
want := strings.TrimSpace(GoToolchainRev)
|
||||
if tsRev != want {
|
||||
if os.Getenv("TS_PERMIT_TOOLCHAIN_MISMATCH") == "1" {
|
||||
fmt.Fprintf(os.Stderr, "tailscale.toolchain.rev = %q, want %q; but ignoring due to TS_PERMIT_TOOLCHAIN_MISMATCH=1\n", tsRev, want)
|
||||
return
|
||||
}
|
||||
panic(fmt.Sprintf("binary built with tailscale_go build tag but Go toolchain %q doesn't match github.com/tailscale/tailscale expected value %q; override this failure with TS_PERMIT_TOOLCHAIN_MISMATCH=1", tsRev, want))
|
||||
}
|
||||
}
|
||||
@@ -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,21 +9,14 @@
|
||||
package atomicfile // import "tailscale.com/atomicfile"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// WriteFile writes data to filename+some suffix, then renames it into filename.
|
||||
// The perm argument is ignored on Windows, but if the target filename already
|
||||
// exists then the target file's attributes and ACLs are preserved. If the target
|
||||
// filename already exists but is not a regular file, WriteFile returns an error.
|
||||
// WriteFile writes data to filename+some suffix, then renames it
|
||||
// into filename. The perm argument is ignored on Windows.
|
||||
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
fi, err := os.Stat(filename)
|
||||
if err == nil && !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s already exists and is not a regular file", filename)
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -48,5 +42,5 @@ func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return rename(tmpName, filename)
|
||||
return os.Rename(tmpName, filename)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func rename(srcFile, destFile string) error {
|
||||
return os.Rename(srcFile, destFile)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !js && !windows
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoesNotOverwriteIrregularFiles(t *testing.T) {
|
||||
// Per tailscale/tailscale#7658 as one example, almost any imagined use of
|
||||
// atomicfile.Write should likely not attempt to overwrite an irregular file
|
||||
// such as a device node, socket, or named pipe.
|
||||
|
||||
const filename = "TestDoesNotOverwriteIrregularFiles"
|
||||
var path string
|
||||
// macOS private temp does not allow unix socket creation, but /tmp does.
|
||||
if runtime.GOOS == "darwin" {
|
||||
path = filepath.Join("/tmp", filename)
|
||||
t.Cleanup(func() { os.Remove(path) })
|
||||
} else {
|
||||
path = filepath.Join(t.TempDir(), filename)
|
||||
}
|
||||
|
||||
// The least troublesome thing to make that is not a file is a unix socket.
|
||||
// Making a null device sadly requires root.
|
||||
l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
err = WriteFile(path, []byte("hello"), 0644)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not a regular file") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func rename(srcFile, destFile string) error {
|
||||
// Use replaceFile when possible to preserve the original file's attributes and ACLs.
|
||||
if err := replaceFile(destFile, srcFile); err == nil || err != windows.ERROR_FILE_NOT_FOUND {
|
||||
return err
|
||||
}
|
||||
// destFile doesn't exist. Just do a normal rename.
|
||||
return os.Rename(srcFile, destFile)
|
||||
}
|
||||
|
||||
func replaceFile(destFile, srcFile string) error {
|
||||
destFile16, err := windows.UTF16PtrFromString(destFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile16, err := windows.UTF16PtrFromString(srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return replaceFileW(destFile16, srcFile16, nil, 0, nil, nil)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var _SECURITY_RESOURCE_MANAGER_AUTHORITY = windows.SidIdentifierAuthority{[6]byte{0, 0, 0, 0, 0, 9}}
|
||||
|
||||
// makeRandomSID generates a SID derived from a v4 GUID.
|
||||
// This is basically the same algorithm used by browser sandboxes for generating
|
||||
// random SIDs.
|
||||
func makeRandomSID() (*windows.SID, error) {
|
||||
guid, err := windows.GenerateGUID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rids := *((*[4]uint32)(unsafe.Pointer(&guid)))
|
||||
|
||||
var pSID *windows.SID
|
||||
if err := windows.AllocateAndInitializeSid(&_SECURITY_RESOURCE_MANAGER_AUTHORITY, 4, rids[0], rids[1], rids[2], rids[3], 0, 0, 0, 0, &pSID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer windows.FreeSid(pSID)
|
||||
|
||||
// Make a copy that lives on the Go heap
|
||||
return pSID.Copy()
|
||||
}
|
||||
|
||||
func getExistingFileSD(name string) (*windows.SECURITY_DESCRIPTOR, error) {
|
||||
const infoFlags = windows.DACL_SECURITY_INFORMATION
|
||||
return windows.GetNamedSecurityInfo(name, windows.SE_FILE_OBJECT, infoFlags)
|
||||
}
|
||||
|
||||
func getExistingFileDACL(name string) (*windows.ACL, error) {
|
||||
sd, err := getExistingFileSD(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dacl, _, err := sd.DACL()
|
||||
return dacl, err
|
||||
}
|
||||
|
||||
func addDenyACEForRandomSID(dacl *windows.ACL) (*windows.ACL, error) {
|
||||
randomSID, err := makeRandomSID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
randomSIDTrustee := windows.TRUSTEE{nil, windows.NO_MULTIPLE_TRUSTEE,
|
||||
windows.TRUSTEE_IS_SID, windows.TRUSTEE_IS_UNKNOWN,
|
||||
windows.TrusteeValueFromSID(randomSID)}
|
||||
|
||||
entries := []windows.EXPLICIT_ACCESS{
|
||||
{
|
||||
windows.GENERIC_ALL,
|
||||
windows.DENY_ACCESS,
|
||||
windows.NO_INHERITANCE,
|
||||
randomSIDTrustee,
|
||||
},
|
||||
}
|
||||
|
||||
return windows.ACLFromEntries(entries, dacl)
|
||||
}
|
||||
|
||||
func setExistingFileDACL(name string, dacl *windows.ACL) error {
|
||||
return windows.SetNamedSecurityInfo(name, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
|
||||
}
|
||||
|
||||
// makeOrigFileWithCustomDACL creates a new, temporary file with a custom
|
||||
// DACL that we can check for later. It returns the name of the temporary
|
||||
// file and the security descriptor for the file in SDDL format.
|
||||
func makeOrigFileWithCustomDACL() (name, sddl string, err error) {
|
||||
f, err := os.CreateTemp("", "foo*.tmp")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
name = f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f = nil
|
||||
defer func() {
|
||||
if err != nil {
|
||||
os.Remove(name)
|
||||
}
|
||||
}()
|
||||
|
||||
dacl, err := getExistingFileDACL(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Add a harmless, deny-only ACE for a random SID that isn't used for anything
|
||||
// (but that we can check for later).
|
||||
dacl, err = addDenyACEForRandomSID(dacl)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := setExistingFileDACL(name, dacl); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
sd, err := getExistingFileSD(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return name, sd.String(), nil
|
||||
}
|
||||
|
||||
func TestPreserveSecurityInfo(t *testing.T) {
|
||||
// Make a test file with a custom ACL.
|
||||
origFileName, want, err := makeOrigFileWithCustomDACL()
|
||||
if err != nil {
|
||||
t.Fatalf("makeOrigFileWithCustomDACL returned %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
os.Remove(origFileName)
|
||||
})
|
||||
|
||||
if err := WriteFile(origFileName, []byte{}, 0); err != nil {
|
||||
t.Fatalf("WriteFile returned %v", err)
|
||||
}
|
||||
|
||||
// We expect origFileName's security descriptor to be unchanged despite
|
||||
// the WriteFile call.
|
||||
sd, err := getExistingFileSD(origFileName)
|
||||
if err != nil {
|
||||
t.Fatalf("getExistingFileSD(%q) returned %v", origFileName, err)
|
||||
}
|
||||
|
||||
if got := sd.String(); got != want {
|
||||
t.Errorf("security descriptor comparison failed: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go
|
||||
|
||||
//sys replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) [int32(failretval)==0] = kernel32.ReplaceFileW
|
||||
@@ -1,52 +0,0 @@
|
||||
// Code generated by 'go generate'; DO NOT EDIT.
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var _ unsafe.Pointer
|
||||
|
||||
// Do the interface allocations only once for common
|
||||
// Errno values.
|
||||
const (
|
||||
errnoERROR_IO_PENDING = 997
|
||||
)
|
||||
|
||||
var (
|
||||
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
|
||||
errERROR_EINVAL error = syscall.EINVAL
|
||||
)
|
||||
|
||||
// errnoErr returns common boxed Errno values, to prevent
|
||||
// allocations at runtime.
|
||||
func errnoErr(e syscall.Errno) error {
|
||||
switch e {
|
||||
case 0:
|
||||
return errERROR_EINVAL
|
||||
case errnoERROR_IO_PENDING:
|
||||
return errERROR_IO_PENDING
|
||||
}
|
||||
// TODO: add more here, after collecting data on the common
|
||||
// error values see on Windows. (perhaps when running
|
||||
// all.bat?)
|
||||
return e
|
||||
}
|
||||
|
||||
var (
|
||||
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
|
||||
procReplaceFileW = modkernel32.NewProc("ReplaceFileW")
|
||||
)
|
||||
|
||||
func replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) {
|
||||
r1, _, e1 := syscall.Syscall6(procReplaceFileW.Addr(), 6, uintptr(unsafe.Pointer(replaced)), uintptr(unsafe.Pointer(replacement)), uintptr(unsafe.Pointer(backup)), uintptr(flags), uintptr(exclude), uintptr(reserved))
|
||||
if int32(r1) == 0 {
|
||||
err = errnoErr(e1)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -11,25 +11,42 @@
|
||||
|
||||
set -eu
|
||||
|
||||
go="go"
|
||||
if [ -n "${TS_USE_TOOLCHAIN:-}" ]; then
|
||||
go="./tool/go"
|
||||
IFS=".$IFS" read -r major minor patch <VERSION.txt
|
||||
git_hash=$(git rev-parse HEAD)
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git_hash="${git_hash}-dirty"
|
||||
fi
|
||||
base_hash=$(git rev-list --max-count=1 HEAD -- VERSION.txt)
|
||||
change_count=$(git rev-list --count HEAD "^$base_hash")
|
||||
short_hash=$(echo "$git_hash" | cut -c1-9)
|
||||
|
||||
if expr "$minor" : "[0-9]*[13579]$" >/dev/null; then
|
||||
patch="$change_count"
|
||||
change_suffix=""
|
||||
elif [ "$change_count" != "0" ]; then
|
||||
change_suffix="-$change_count"
|
||||
else
|
||||
change_suffix=""
|
||||
fi
|
||||
|
||||
eval `CGO_ENABLED=0 GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion`
|
||||
long_suffix="$change_suffix-t$short_hash"
|
||||
MINOR="$major.$minor"
|
||||
SHORT="$MINOR.$patch"
|
||||
LONG="${SHORT}$long_suffix"
|
||||
GIT_HASH="$git_hash"
|
||||
|
||||
if [ "$1" = "shellvars" ]; then
|
||||
cat <<EOF
|
||||
VERSION_MINOR="$VERSION_MINOR"
|
||||
VERSION_SHORT="$VERSION_SHORT"
|
||||
VERSION_LONG="$VERSION_LONG"
|
||||
VERSION_GIT_HASH="$VERSION_GIT_HASH"
|
||||
VERSION_MINOR="$MINOR"
|
||||
VERSION_SHORT="$SHORT"
|
||||
VERSION_LONG="$LONG"
|
||||
VERSION_GIT_HASH="$GIT_HASH"
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tags=""
|
||||
ldflags="-X tailscale.com/version.longStamp=${VERSION_LONG} -X tailscale.com/version.shortStamp=${VERSION_SHORT}"
|
||||
ldflags="-X tailscale.com/version.Long=${LONG} -X tailscale.com/version.Short=${SHORT} -X tailscale.com/version.GitCommit=${GIT_HASH}"
|
||||
|
||||
# build_dist.sh arguments must precede go build arguments.
|
||||
while [ "$#" -gt 1 ]; do
|
||||
@@ -37,7 +54,7 @@ while [ "$#" -gt 1 ]; do
|
||||
--extra-small)
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture"
|
||||
tags="${tags:+$tags,}ts_omit_aws"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
@@ -49,4 +66,4 @@ while [ "$#" -gt 1 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
exec $go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
|
||||
exec ./tool/go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
|
||||
|
||||
119
build_docker.sh
@@ -1,97 +1,48 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# This script builds Tailscale container images using
|
||||
# github.com/tailscale/mkctr.
|
||||
# By default the images will be tagged with the current version and git
|
||||
# hash of this repository as produced by ./cmd/mkversion.
|
||||
# This is the image build mechanim used to build the official Tailscale
|
||||
# container images.
|
||||
# Runs `go build` with flags configured for docker distribution. All
|
||||
# it does differently from `go build` is burn git commit and version
|
||||
# information into the binaries inside docker, so that we can track down user
|
||||
# issues.
|
||||
#
|
||||
############################################################################
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# See current 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"
|
||||
export PATH=$PWD/tool:$PATH
|
||||
|
||||
eval "$(./build_dist.sh shellvars)"
|
||||
|
||||
DEFAULT_TARGET="client"
|
||||
eval $(./build_dist.sh shellvars)
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.19"
|
||||
# Set a few pre-defined OCI annotations. The source annotation is used by tools such as Renovate that scan the linked
|
||||
# Github repo to find release notes for any new image tags. Note that for official Tailscale images the default
|
||||
# annotations defined here will be overriden by release scripts that call this script.
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
|
||||
DEFAULT_ANNOTATIONS="org.opencontainers.image.source=https://github.com/tailscale/tailscale/blob/main/build_docker.sh,org.opencontainers.image.vendor=Tailscale"
|
||||
DEFAULT_REPOS="tailscale/tailscale,ghcr.io/tailscale/tailscale"
|
||||
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.16"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
TARGET="${TARGET:-${DEFAULT_TARGET}}"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
TAGS="${TAGS:-${DEFAULT_TAGS}}"
|
||||
BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
PLATFORM="${PLATFORM:-}" # default to all platforms
|
||||
# OCI annotations that will be added to the image.
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
ANNOTATIONS="${ANNOTATIONS:-${DEFAULT_ANNOTATIONS}}"
|
||||
|
||||
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}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
k8s-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}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
k8s-nameserver)
|
||||
DEFAULT_REPOS="tailscale/k8s-nameserver"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-nameserver" \
|
||||
--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}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/k8s-nameserver
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.Long=${VERSION_LONG} \
|
||||
-X tailscale.com/version.Short=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
|
||||
--files="docs/k8s/run.sh:/tailscale/run.sh" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/bin/sh /tailscale/run.sh
|
||||
|
||||
@@ -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 chirp implements a client to communicate with the BIRD Internet
|
||||
// Routing Daemon.
|
||||
@@ -23,17 +24,11 @@ func New(socket string) (*BIRDClient, error) {
|
||||
return newWithTimeout(socket, responseTimeout)
|
||||
}
|
||||
|
||||
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
|
||||
func newWithTimeout(socket string, timeout time.Duration) (*BIRDClient, 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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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 chirp
|
||||
|
||||
import (
|
||||
@@ -105,10 +106,10 @@ func TestChirp(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("rando"); err == nil {
|
||||
t.Fatalf("enabling %q succeeded", "rando")
|
||||
t.Fatalf("enabling %q succeded", "rando")
|
||||
}
|
||||
if err := c.DisableProtocol("rando"); err == nil {
|
||||
t.Fatalf("disabling %q succeeded", "rando")
|
||||
t.Fatalf("disabling %q succeded", "rando")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoIsPeerNotFound(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
lc := &Client{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var std net.Dialer
|
||||
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
|
||||
},
|
||||
}
|
||||
var k key.NodePublic
|
||||
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := lc.WhoIsNodeKey(context.Background(), k)
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
// Make sure we don't again accidentally bring in a dependency on
|
||||
// drive or its transitive dependencies
|
||||
"testing": "do not use testing package in production code",
|
||||
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
// tsLogo represents the Tailscale logo displayed as the systray icon.
|
||||
type tsLogo struct {
|
||||
// dots represents the state of the 3x3 dot grid in the logo.
|
||||
// A 0 represents a gray dot, any other value is a white dot.
|
||||
dots [9]byte
|
||||
|
||||
// dotMask returns an image mask to be used when rendering the logo dots.
|
||||
dotMask func(dc *gg.Context, borderUnits int, radius int) *image.Alpha
|
||||
|
||||
// overlay is called after the dots are rendered to draw an additional overlay.
|
||||
overlay func(dc *gg.Context, borderUnits int, radius int)
|
||||
}
|
||||
|
||||
var (
|
||||
// disconnected is all gray dots
|
||||
disconnected = tsLogo{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}}
|
||||
|
||||
// connected is the normal Tailscale logo
|
||||
connected = tsLogo{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 1, 0,
|
||||
}}
|
||||
|
||||
// loading is a special tsLogo value that is not meant to be rendered directly,
|
||||
// but indicates that the loading animation should be shown.
|
||||
loading = tsLogo{dots: [9]byte{'l', 'o', 'a', 'd', 'i', 'n', 'g'}}
|
||||
|
||||
// loadingIcons are shown in sequence as an animated loading icon.
|
||||
loadingLogos = []tsLogo{
|
||||
{dots: [9]byte{
|
||||
0, 1, 1,
|
||||
1, 0, 1,
|
||||
0, 0, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 1,
|
||||
0, 0, 1,
|
||||
0, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 1,
|
||||
0, 0, 0,
|
||||
0, 0, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 1,
|
||||
0, 1, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 1,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 1,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
1, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
1, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 0,
|
||||
0, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 0,
|
||||
0, 1, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 0, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 0,
|
||||
0, 1, 1,
|
||||
1, 0, 1,
|
||||
}},
|
||||
}
|
||||
|
||||
// exitNodeOnline is the Tailscale logo with an additional arrow overlay in the corner.
|
||||
exitNodeOnline = tsLogo{
|
||||
dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 1, 0,
|
||||
},
|
||||
// draw an arrow mask in the bottom right corner with a reasonably thick line width.
|
||||
dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
|
||||
x1 := r * (bu + 3.5)
|
||||
y := r * (bu + 7)
|
||||
x2 := x1 + (r * 5)
|
||||
|
||||
mc := gg.NewContext(dc.Width(), dc.Height())
|
||||
mc.DrawLine(x1, y, x2, y) // arrow center line
|
||||
mc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) // top of arrow tip
|
||||
mc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) // bottom of arrow tip
|
||||
mc.SetLineWidth(r * 3)
|
||||
mc.Stroke()
|
||||
return mc.AsMask()
|
||||
},
|
||||
// draw an arrow in the bottom right corner over the masked area.
|
||||
overlay: func(dc *gg.Context, borderUnits int, radius int) {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
|
||||
x1 := r * (bu + 3.5)
|
||||
y := r * (bu + 7)
|
||||
x2 := x1 + (r * 5)
|
||||
|
||||
dc.DrawLine(x1, y, x2, y) // arrow center line
|
||||
dc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) // top of arrow tip
|
||||
dc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) // bottom of arrow tip
|
||||
dc.SetColor(fg)
|
||||
dc.SetLineWidth(r)
|
||||
dc.Stroke()
|
||||
},
|
||||
}
|
||||
|
||||
// exitNodeOffline is the Tailscale logo with a red "x" in the corner.
|
||||
exitNodeOffline = tsLogo{
|
||||
dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 1, 0,
|
||||
},
|
||||
// Draw a square that hides the four dots in the bottom right corner,
|
||||
dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
x := r * (bu + 3)
|
||||
|
||||
mc := gg.NewContext(dc.Width(), dc.Height())
|
||||
mc.DrawRectangle(x, x, r*6, r*6)
|
||||
mc.Fill()
|
||||
return mc.AsMask()
|
||||
},
|
||||
// draw a red "x" over the bottom right corner.
|
||||
overlay: func(dc *gg.Context, borderUnits int, radius int) {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
|
||||
x1 := r * (bu + 4)
|
||||
x2 := x1 + (r * 3.5)
|
||||
dc.DrawLine(x1, x1, x2, x2) // top-left to bottom-right stroke
|
||||
dc.DrawLine(x1, x2, x2, x1) // bottom-left to top-right stroke
|
||||
dc.SetColor(red)
|
||||
dc.SetLineWidth(r)
|
||||
dc.Stroke()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
bg = color.NRGBA{0, 0, 0, 255}
|
||||
fg = color.NRGBA{255, 255, 255, 255}
|
||||
gray = color.NRGBA{255, 255, 255, 102}
|
||||
red = color.NRGBA{229, 111, 74, 255}
|
||||
)
|
||||
|
||||
// render returns a PNG image of the logo.
|
||||
func (logo tsLogo) render() *bytes.Buffer {
|
||||
const borderUnits = 1
|
||||
return logo.renderWithBorder(borderUnits)
|
||||
}
|
||||
|
||||
// renderWithBorder returns a PNG image of the logo with the specified border width.
|
||||
// One border unit is equal to the radius of a tailscale logo dot.
|
||||
func (logo tsLogo) renderWithBorder(borderUnits int) *bytes.Buffer {
|
||||
const radius = 25
|
||||
dim := radius * (8 + borderUnits*2)
|
||||
|
||||
dc := gg.NewContext(dim, dim)
|
||||
dc.DrawRectangle(0, 0, float64(dim), float64(dim))
|
||||
dc.SetColor(bg)
|
||||
dc.Fill()
|
||||
|
||||
if logo.dotMask != nil {
|
||||
mask := logo.dotMask(dc, borderUnits, radius)
|
||||
dc.SetMask(mask)
|
||||
dc.InvertMask()
|
||||
}
|
||||
|
||||
for y := 0; y < 3; y++ {
|
||||
for x := 0; x < 3; x++ {
|
||||
px := (borderUnits + 1 + 3*x) * radius
|
||||
py := (borderUnits + 1 + 3*y) * radius
|
||||
col := fg
|
||||
if logo.dots[y*3+x] == 0 {
|
||||
col = gray
|
||||
}
|
||||
dc.DrawCircle(float64(px), float64(py), radius)
|
||||
dc.SetColor(col)
|
||||
dc.Fill()
|
||||
}
|
||||
}
|
||||
|
||||
if logo.overlay != nil {
|
||||
dc.ResetClip()
|
||||
logo.overlay(dc, borderUnits, radius)
|
||||
}
|
||||
|
||||
b := bytes.NewBuffer(nil)
|
||||
png.Encode(b, dc.Image())
|
||||
return b
|
||||
}
|
||||
|
||||
// setAppIcon renders logo and sets it as the systray icon.
|
||||
func setAppIcon(icon tsLogo) {
|
||||
if icon.dots == loading.dots {
|
||||
startLoadingAnimation()
|
||||
} else {
|
||||
stopLoadingAnimation()
|
||||
systray.SetIcon(icon.render().Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
loadingMu sync.Mutex // protects loadingCancel
|
||||
|
||||
// loadingCancel stops the loading animation in the systray icon.
|
||||
// This is nil if the animation is not currently active.
|
||||
loadingCancel func()
|
||||
)
|
||||
|
||||
// startLoadingAnimation starts the animated loading icon in the system tray.
|
||||
// The animation continues until [stopLoadingAnimation] is called.
|
||||
// If the loading animation is already active, this func does nothing.
|
||||
func startLoadingAnimation() {
|
||||
loadingMu.Lock()
|
||||
defer loadingMu.Unlock()
|
||||
|
||||
if loadingCancel != nil {
|
||||
// loading icon already displayed
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, loadingCancel = context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(500 * time.Millisecond)
|
||||
var i int
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
systray.SetIcon(loadingLogos[i].render().Bytes())
|
||||
i++
|
||||
if i >= len(loadingLogos) {
|
||||
i = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopLoadingAnimation stops the animated loading icon in the system tray.
|
||||
// If the loading animation is not currently active, this func does nothing.
|
||||
func stopLoadingAnimation() {
|
||||
loadingMu.Lock()
|
||||
defer loadingMu.Unlock()
|
||||
|
||||
if loadingCancel != nil {
|
||||
loadingCancel()
|
||||
loadingCancel = nil
|
||||
}
|
||||
}
|
||||
@@ -1,740 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
// Package systray provides a minimal Tailscale systray application.
|
||||
package systray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/atotto/clipboard"
|
||||
dbus "github.com/godbus/dbus/v5"
|
||||
"github.com/toqueteos/webbrowser"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/util/stringsx"
|
||||
)
|
||||
|
||||
var (
|
||||
// newMenuDelay is the amount of time to sleep after creating a new menu,
|
||||
// but before adding items to it. This works around a bug in some dbus implementations.
|
||||
newMenuDelay time.Duration
|
||||
|
||||
// if true, treat all mullvad exit node countries as single-city.
|
||||
// Instead of rendering a submenu with cities, just select the highest-priority peer.
|
||||
hideMullvadCities bool
|
||||
)
|
||||
|
||||
// Run starts the systray menu and blocks until the menu exits.
|
||||
func (menu *Menu) Run() {
|
||||
menu.updateState()
|
||||
|
||||
// exit cleanly on SIGINT and SIGTERM
|
||||
go func() {
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case <-interrupt:
|
||||
menu.onExit()
|
||||
case <-menu.bgCtx.Done():
|
||||
}
|
||||
}()
|
||||
go menu.lc.IncrementCounter(menu.bgCtx, "systray_start", 1)
|
||||
|
||||
systray.Run(menu.onReady, menu.onExit)
|
||||
}
|
||||
|
||||
// Menu represents the systray menu, its items, and the current Tailscale state.
|
||||
type Menu struct {
|
||||
mu sync.Mutex // protects the entire Menu
|
||||
|
||||
lc local.Client
|
||||
status *ipnstate.Status
|
||||
curProfile ipn.LoginProfile
|
||||
allProfiles []ipn.LoginProfile
|
||||
|
||||
// readonly is whether the systray app is running in read-only mode.
|
||||
// This is set if LocalAPI returns a permission error,
|
||||
// typically because the user needs to run `tailscale set --operator=$USER`.
|
||||
readonly bool
|
||||
|
||||
bgCtx context.Context // ctx for background tasks not involving menu item clicks
|
||||
bgCancel context.CancelFunc
|
||||
|
||||
// Top-level menu items
|
||||
connect *systray.MenuItem
|
||||
disconnect *systray.MenuItem
|
||||
self *systray.MenuItem
|
||||
exitNodes *systray.MenuItem
|
||||
more *systray.MenuItem
|
||||
quit *systray.MenuItem
|
||||
|
||||
rebuildCh chan struct{} // triggers a menu rebuild
|
||||
accountsCh chan ipn.ProfileID
|
||||
exitNodeCh chan tailcfg.StableNodeID // ID of selected exit node
|
||||
|
||||
eventCancel context.CancelFunc // cancel eventLoop
|
||||
|
||||
notificationIcon *os.File // icon used for desktop notifications
|
||||
}
|
||||
|
||||
func (menu *Menu) init() {
|
||||
if menu.bgCtx != nil {
|
||||
// already initialized
|
||||
return
|
||||
}
|
||||
|
||||
menu.rebuildCh = make(chan struct{}, 1)
|
||||
menu.accountsCh = make(chan ipn.ProfileID)
|
||||
menu.exitNodeCh = make(chan tailcfg.StableNodeID)
|
||||
|
||||
// dbus wants a file path for notification icons, so copy to a temp file.
|
||||
menu.notificationIcon, _ = os.CreateTemp("", "tailscale-systray.png")
|
||||
io.Copy(menu.notificationIcon, connected.renderWithBorder(3))
|
||||
|
||||
menu.bgCtx, menu.bgCancel = context.WithCancel(context.Background())
|
||||
go menu.watchIPNBus()
|
||||
}
|
||||
|
||||
func init() {
|
||||
if runtime.GOOS != "linux" {
|
||||
// so far, these tweaks are only needed on Linux
|
||||
return
|
||||
}
|
||||
|
||||
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
|
||||
switch desktop {
|
||||
case "gnome":
|
||||
// GNOME expands submenus downward in the main menu, rather than flyouts to the side.
|
||||
// Either as a result of that or another limitation, there seems to be a maximum depth of submenus.
|
||||
// Mullvad countries that have a city submenu are not being rendered, and so can't be selected.
|
||||
// Handle this by simply treating all mullvad countries as single-city and select the best peer.
|
||||
hideMullvadCities = true
|
||||
case "kde":
|
||||
// KDE doesn't need a delay, and actually won't render submenus
|
||||
// if we delay for more than about 400µs.
|
||||
newMenuDelay = 0
|
||||
default:
|
||||
// Add a slight delay to ensure the menu is created before adding items.
|
||||
//
|
||||
// Systray implementations that use libdbusmenu sometimes process messages out of order,
|
||||
// resulting in errors such as:
|
||||
// (waybar:153009): LIBDBUSMENU-GTK-WARNING **: 18:07:11.551: Children but no menu, someone's been naughty with their 'children-display' property: 'submenu'
|
||||
//
|
||||
// See also: https://github.com/fyne-io/systray/issues/12
|
||||
newMenuDelay = 10 * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
// onReady is called by the systray package when the menu is ready to be built.
|
||||
func (menu *Menu) onReady() {
|
||||
log.Printf("starting")
|
||||
setAppIcon(disconnected)
|
||||
menu.rebuild()
|
||||
}
|
||||
|
||||
// updateState updates the Menu state from the Tailscale local client.
|
||||
func (menu *Menu) updateState() {
|
||||
menu.mu.Lock()
|
||||
defer menu.mu.Unlock()
|
||||
menu.init()
|
||||
|
||||
menu.readonly = false
|
||||
|
||||
var err error
|
||||
menu.status, err = menu.lc.Status(menu.bgCtx)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
menu.curProfile, menu.allProfiles, err = menu.lc.ProfileStatus(menu.bgCtx)
|
||||
if err != nil {
|
||||
if local.IsAccessDeniedError(err) {
|
||||
menu.readonly = true
|
||||
}
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
// rebuild the systray menu based on the current Tailscale state.
|
||||
//
|
||||
// We currently rebuild the entire menu because it is not easy to update the existing menu.
|
||||
// You cannot iterate over the items in a menu, nor can you remove some items like separators.
|
||||
// So for now we rebuild the whole thing, and can optimize this later if needed.
|
||||
func (menu *Menu) rebuild() {
|
||||
menu.mu.Lock()
|
||||
defer menu.mu.Unlock()
|
||||
menu.init()
|
||||
|
||||
if menu.eventCancel != nil {
|
||||
menu.eventCancel()
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx, menu.eventCancel = context.WithCancel(ctx)
|
||||
|
||||
systray.ResetMenu()
|
||||
|
||||
if menu.readonly {
|
||||
const readonlyMsg = "No permission to manage Tailscale.\nSee tailscale.com/s/cli-operator"
|
||||
m := systray.AddMenuItem(readonlyMsg, "")
|
||||
onClick(ctx, m, func(_ context.Context) {
|
||||
webbrowser.Open("https://tailscale.com/s/cli-operator")
|
||||
})
|
||||
systray.AddSeparator()
|
||||
}
|
||||
|
||||
menu.connect = systray.AddMenuItem("Connect", "")
|
||||
menu.disconnect = systray.AddMenuItem("Disconnect", "")
|
||||
menu.disconnect.Hide()
|
||||
systray.AddSeparator()
|
||||
|
||||
// delay to prevent race setting icon on first start
|
||||
time.Sleep(newMenuDelay)
|
||||
|
||||
// Set systray menu icon and title.
|
||||
// Also adjust connect/disconnect menu items if needed.
|
||||
var backendState string
|
||||
if menu.status != nil {
|
||||
backendState = menu.status.BackendState
|
||||
}
|
||||
switch backendState {
|
||||
case ipn.Running.String():
|
||||
if menu.status.ExitNodeStatus != nil && !menu.status.ExitNodeStatus.ID.IsZero() {
|
||||
if menu.status.ExitNodeStatus.Online {
|
||||
setTooltip("Using exit node")
|
||||
setAppIcon(exitNodeOnline)
|
||||
} else {
|
||||
setTooltip("Exit node offline")
|
||||
setAppIcon(exitNodeOffline)
|
||||
}
|
||||
} else {
|
||||
setTooltip(fmt.Sprintf("Connected to %s", menu.status.CurrentTailnet.Name))
|
||||
setAppIcon(connected)
|
||||
}
|
||||
menu.connect.SetTitle("Connected")
|
||||
menu.connect.Disable()
|
||||
menu.disconnect.Show()
|
||||
menu.disconnect.Enable()
|
||||
case ipn.Starting.String():
|
||||
setTooltip("Connecting")
|
||||
setAppIcon(loading)
|
||||
default:
|
||||
setTooltip("Disconnected")
|
||||
setAppIcon(disconnected)
|
||||
}
|
||||
|
||||
if menu.readonly {
|
||||
menu.connect.Disable()
|
||||
menu.disconnect.Disable()
|
||||
}
|
||||
|
||||
account := "Account"
|
||||
if pt := profileTitle(menu.curProfile); pt != "" {
|
||||
account = pt
|
||||
}
|
||||
if !menu.readonly {
|
||||
accounts := systray.AddMenuItem(account, "")
|
||||
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
|
||||
time.Sleep(newMenuDelay)
|
||||
for _, profile := range menu.allProfiles {
|
||||
title := profileTitle(profile)
|
||||
var item *systray.MenuItem
|
||||
if profile.ID == menu.curProfile.ID {
|
||||
item = accounts.AddSubMenuItemCheckbox(title, "", true)
|
||||
} else {
|
||||
item = accounts.AddSubMenuItem(title, "")
|
||||
}
|
||||
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
||||
onClick(ctx, item, func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case menu.accountsCh <- profile.ID:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if menu.status != nil && menu.status.Self != nil && len(menu.status.Self.TailscaleIPs) > 0 {
|
||||
title := fmt.Sprintf("This Device: %s (%s)", menu.status.Self.HostName, menu.status.Self.TailscaleIPs[0])
|
||||
menu.self = systray.AddMenuItem(title, "")
|
||||
} else {
|
||||
menu.self = systray.AddMenuItem("This Device: not connected", "")
|
||||
menu.self.Disable()
|
||||
}
|
||||
systray.AddSeparator()
|
||||
|
||||
if !menu.readonly {
|
||||
menu.rebuildExitNodeMenu(ctx)
|
||||
}
|
||||
|
||||
if menu.status != nil {
|
||||
menu.more = systray.AddMenuItem("More settings", "")
|
||||
onClick(ctx, menu.more, func(_ context.Context) {
|
||||
webbrowser.Open("http://100.100.100.100/")
|
||||
})
|
||||
}
|
||||
|
||||
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
|
||||
menu.quit.Enable()
|
||||
|
||||
go menu.eventLoop(ctx)
|
||||
}
|
||||
|
||||
// profileTitle returns the title string for a profile menu item.
|
||||
func profileTitle(profile ipn.LoginProfile) string {
|
||||
title := profile.Name
|
||||
if profile.NetworkProfile.DomainName != "" {
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
// windows and mac don't support multi-line menu
|
||||
title += " (" + profile.NetworkProfile.DomainName + ")"
|
||||
} else {
|
||||
title += "\n" + profile.NetworkProfile.DomainName
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.Mutex
|
||||
httpCache = map[string][]byte{} // URL => response body
|
||||
)
|
||||
|
||||
// setRemoteIcon sets the icon for menu to the specified remote image.
|
||||
// Remote images are fetched as needed and cached.
|
||||
func setRemoteIcon(menu *systray.MenuItem, urlStr string) {
|
||||
if menu == nil || urlStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cacheMu.Lock()
|
||||
b, ok := httpCache[urlStr]
|
||||
if !ok {
|
||||
resp, err := http.Get(urlStr)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
b, _ = io.ReadAll(resp.Body)
|
||||
httpCache[urlStr] = b
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
|
||||
if len(b) > 0 {
|
||||
menu.SetIcon(b)
|
||||
}
|
||||
}
|
||||
|
||||
// setTooltip sets the tooltip text for the systray icon.
|
||||
func setTooltip(text string) {
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
|
||||
systray.SetTooltip(text)
|
||||
} else {
|
||||
// on Linux, SetTitle actually sets the tooltip
|
||||
systray.SetTitle(text)
|
||||
}
|
||||
}
|
||||
|
||||
// eventLoop is the main event loop for handling click events on menu items
|
||||
// and responding to Tailscale state changes.
|
||||
// This method does not return until ctx.Done is closed.
|
||||
func (menu *Menu) eventLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-menu.rebuildCh:
|
||||
menu.updateState()
|
||||
menu.rebuild()
|
||||
case <-menu.connect.ClickedCh:
|
||||
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
WantRunning: true,
|
||||
},
|
||||
WantRunningSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("error connecting: %v", err)
|
||||
}
|
||||
|
||||
case <-menu.disconnect.ClickedCh:
|
||||
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
WantRunning: false,
|
||||
},
|
||||
WantRunningSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("error disconnecting: %v", err)
|
||||
}
|
||||
|
||||
case <-menu.self.ClickedCh:
|
||||
menu.copyTailscaleIP(menu.status.Self)
|
||||
|
||||
case id := <-menu.accountsCh:
|
||||
if err := menu.lc.SwitchProfile(ctx, id); err != nil {
|
||||
log.Printf("error switching to profile ID %v: %v", id, err)
|
||||
}
|
||||
|
||||
case exitNode := <-menu.exitNodeCh:
|
||||
if exitNode.IsZero() {
|
||||
log.Print("disable exit node")
|
||||
if err := menu.lc.SetUseExitNode(ctx, false); err != nil {
|
||||
log.Printf("error disabling exit node: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("enable exit node: %v", exitNode)
|
||||
mp := &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
ExitNodeID: exitNode,
|
||||
},
|
||||
ExitNodeIDSet: true,
|
||||
}
|
||||
if _, err := menu.lc.EditPrefs(ctx, mp); err != nil {
|
||||
log.Printf("error setting exit node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
case <-menu.quit.ClickedCh:
|
||||
systray.Quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onClick registers a click handler for a menu item.
|
||||
func onClick(ctx context.Context, item *systray.MenuItem, fn func(ctx context.Context)) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-item.ClickedCh:
|
||||
fn(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
|
||||
// This method does not return.
|
||||
func (menu *Menu) watchIPNBus() {
|
||||
for {
|
||||
if err := menu.watchIPNBusInner(); err != nil {
|
||||
log.Println(err)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// If the context got canceled, we will never be able to
|
||||
// reconnect to IPN bus, so exit the process.
|
||||
log.Fatalf("watchIPNBus: %v", err)
|
||||
}
|
||||
}
|
||||
// If our watch connection breaks, wait a bit before reconnecting. No
|
||||
// reason to spam the logs if e.g. tailscaled is restarting or goes
|
||||
// down.
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (menu *Menu) watchIPNBusInner() error {
|
||||
watcher, err := menu.lc.WatchIPNBus(menu.bgCtx, ipn.NotifyNoPrivateKeys)
|
||||
if err != nil {
|
||||
return fmt.Errorf("watching ipn bus: %w", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
for {
|
||||
select {
|
||||
case <-menu.bgCtx.Done():
|
||||
return nil
|
||||
default:
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipnbus error: %w", err)
|
||||
}
|
||||
var rebuild bool
|
||||
if n.State != nil {
|
||||
log.Printf("new state: %v", n.State)
|
||||
rebuild = true
|
||||
}
|
||||
if n.Prefs != nil {
|
||||
rebuild = true
|
||||
}
|
||||
if rebuild {
|
||||
menu.rebuildCh <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
|
||||
// and sends a notification with the copied value.
|
||||
func (menu *Menu) copyTailscaleIP(device *ipnstate.PeerStatus) {
|
||||
if device == nil || len(device.TailscaleIPs) == 0 {
|
||||
return
|
||||
}
|
||||
name := strings.Split(device.DNSName, ".")[0]
|
||||
ip := device.TailscaleIPs[0].String()
|
||||
err := clipboard.WriteAll(ip)
|
||||
if err != nil {
|
||||
log.Printf("clipboard error: %v", err)
|
||||
}
|
||||
|
||||
menu.sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
|
||||
}
|
||||
|
||||
// sendNotification sends a desktop notification with the given title and content.
|
||||
func (menu *Menu) sendNotification(title, content string) {
|
||||
conn, err := dbus.SessionBus()
|
||||
if err != nil {
|
||||
log.Printf("dbus: %v", err)
|
||||
return
|
||||
}
|
||||
timeout := 3 * time.Second
|
||||
obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
|
||||
call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0),
|
||||
menu.notificationIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
|
||||
if call.Err != nil {
|
||||
log.Printf("dbus: %v", call.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
if menu.status == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := menu.status
|
||||
menu.exitNodes = systray.AddMenuItem("Exit Nodes", "")
|
||||
time.Sleep(newMenuDelay)
|
||||
|
||||
// register a click handler for a menu item to set nodeID as the exit node.
|
||||
setExitNodeOnClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
|
||||
onClick(ctx, item, func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case menu.exitNodeCh <- nodeID:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
|
||||
setExitNodeOnClick(noExitNodeMenu, "")
|
||||
|
||||
// Show recommended exit node if available.
|
||||
if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
|
||||
sugg, err := menu.lc.SuggestExitNode(ctx)
|
||||
if err == nil {
|
||||
title := "Recommended: "
|
||||
if loc := sugg.Location; loc.Valid() && loc.Country() != "" {
|
||||
flag := countryFlag(loc.CountryCode())
|
||||
title += fmt.Sprintf("%s %s: %s", flag, loc.Country(), loc.City())
|
||||
} else {
|
||||
title += strings.Split(sugg.Name, ".")[0]
|
||||
}
|
||||
menu.exitNodes.AddSeparator()
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
||||
setExitNodeOnClick(rm, sugg.ID)
|
||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
||||
rm.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add tailnet exit nodes if present.
|
||||
var tailnetExitNodes []*ipnstate.PeerStatus
|
||||
for _, ps := range status.Peer {
|
||||
if ps.ExitNodeOption && ps.Location == nil {
|
||||
tailnetExitNodes = append(tailnetExitNodes, ps)
|
||||
}
|
||||
}
|
||||
if len(tailnetExitNodes) > 0 {
|
||||
menu.exitNodes.AddSeparator()
|
||||
menu.exitNodes.AddSubMenuItem("Tailnet Exit Nodes", "").Disable()
|
||||
for _, ps := range status.Peer {
|
||||
if !ps.ExitNodeOption || ps.Location != nil {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(ps.DNSName, ".")[0]
|
||||
if !ps.Online {
|
||||
name += " (offline)"
|
||||
}
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
|
||||
if !ps.Online {
|
||||
sm.Disable()
|
||||
}
|
||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
||||
sm.Check()
|
||||
}
|
||||
setExitNodeOnClick(sm, ps.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Add mullvad exit nodes if present.
|
||||
var mullvadExitNodes mullvadPeers
|
||||
if status.Self.CapMap.Contains("mullvad") {
|
||||
mullvadExitNodes = newMullvadPeers(status)
|
||||
}
|
||||
if len(mullvadExitNodes.countries) > 0 {
|
||||
menu.exitNodes.AddSeparator()
|
||||
menu.exitNodes.AddSubMenuItem("Location-based Exit Nodes", "").Disable()
|
||||
mullvadMenu := menu.exitNodes.AddSubMenuItemCheckbox("Mullvad VPN", "", false)
|
||||
|
||||
for _, country := range mullvadExitNodes.sortedCountries() {
|
||||
flag := countryFlag(country.code)
|
||||
countryMenu := mullvadMenu.AddSubMenuItemCheckbox(flag+" "+country.name, "", false)
|
||||
|
||||
// single-city country, no submenu
|
||||
if len(country.cities) == 1 || hideMullvadCities {
|
||||
setExitNodeOnClick(countryMenu, country.best.ID)
|
||||
if status.ExitNodeStatus != nil {
|
||||
for _, city := range country.cities {
|
||||
for _, ps := range city.peers {
|
||||
if status.ExitNodeStatus.ID == ps.ID {
|
||||
mullvadMenu.Check()
|
||||
countryMenu.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// multi-city country, build submenu with "best available" option and cities.
|
||||
time.Sleep(newMenuDelay)
|
||||
bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
|
||||
setExitNodeOnClick(bm, country.best.ID)
|
||||
countryMenu.AddSeparator()
|
||||
|
||||
for _, city := range country.sortedCities() {
|
||||
cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
|
||||
setExitNodeOnClick(cityMenu, city.best.ID)
|
||||
if status.ExitNodeStatus != nil {
|
||||
for _, ps := range city.peers {
|
||||
if status.ExitNodeStatus.ID == ps.ID {
|
||||
mullvadMenu.Check()
|
||||
countryMenu.Check()
|
||||
cityMenu.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: "Allow Local Network Access" and "Run Exit Node" menu items
|
||||
}
|
||||
|
||||
// mullvadPeers contains all mullvad peer nodes, sorted by country and city.
|
||||
type mullvadPeers struct {
|
||||
countries map[string]*mvCountry // country code (uppercase) => country
|
||||
}
|
||||
|
||||
// sortedCountries returns countries containing mullvad nodes, sorted by name.
|
||||
func (mp mullvadPeers) sortedCountries() []*mvCountry {
|
||||
countries := slicesx.MapValues(mp.countries)
|
||||
slices.SortFunc(countries, func(a, b *mvCountry) int {
|
||||
return stringsx.CompareFold(a.name, b.name)
|
||||
})
|
||||
return countries
|
||||
}
|
||||
|
||||
type mvCountry struct {
|
||||
code string
|
||||
name string
|
||||
best *ipnstate.PeerStatus // highest priority peer in the country
|
||||
cities map[string]*mvCity // city code => city
|
||||
}
|
||||
|
||||
// sortedCities returns cities containing mullvad nodes, sorted by name.
|
||||
func (mc *mvCountry) sortedCities() []*mvCity {
|
||||
cities := slicesx.MapValues(mc.cities)
|
||||
slices.SortFunc(cities, func(a, b *mvCity) int {
|
||||
return stringsx.CompareFold(a.name, b.name)
|
||||
})
|
||||
return cities
|
||||
}
|
||||
|
||||
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
|
||||
// It returns the empty string on error.
|
||||
func countryFlag(code string) string {
|
||||
if len(code) != 2 {
|
||||
return ""
|
||||
}
|
||||
runes := make([]rune, 0, 2)
|
||||
for i := range 2 {
|
||||
b := code[i] | 32 // lowercase
|
||||
if b < 'a' || b > 'z' {
|
||||
return ""
|
||||
}
|
||||
// https://en.wikipedia.org/wiki/Regional_indicator_symbol
|
||||
runes = append(runes, 0x1F1E6+rune(b-'a'))
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
type mvCity struct {
|
||||
name string
|
||||
best *ipnstate.PeerStatus // highest priority peer in the city
|
||||
peers []*ipnstate.PeerStatus
|
||||
}
|
||||
|
||||
func newMullvadPeers(status *ipnstate.Status) mullvadPeers {
|
||||
countries := make(map[string]*mvCountry)
|
||||
for _, ps := range status.Peer {
|
||||
if !ps.ExitNodeOption || ps.Location == nil {
|
||||
continue
|
||||
}
|
||||
loc := ps.Location
|
||||
country, ok := countries[loc.CountryCode]
|
||||
if !ok {
|
||||
country = &mvCountry{
|
||||
code: loc.CountryCode,
|
||||
name: loc.Country,
|
||||
cities: make(map[string]*mvCity),
|
||||
}
|
||||
countries[loc.CountryCode] = country
|
||||
}
|
||||
city, ok := countries[loc.CountryCode].cities[loc.CityCode]
|
||||
if !ok {
|
||||
city = &mvCity{
|
||||
name: loc.City,
|
||||
}
|
||||
countries[loc.CountryCode].cities[loc.CityCode] = city
|
||||
}
|
||||
city.peers = append(city.peers, ps)
|
||||
if city.best == nil || ps.Location.Priority > city.best.Location.Priority {
|
||||
city.best = ps
|
||||
}
|
||||
if country.best == nil || ps.Location.Priority > country.best.Location.Priority {
|
||||
country.best = ps
|
||||
}
|
||||
}
|
||||
return mullvadPeers{countries}
|
||||
}
|
||||
|
||||
// onExit is called by the systray package when the menu is exiting.
|
||||
func (menu *Menu) onExit() {
|
||||
log.Printf("exiting")
|
||||
if menu.bgCancel != nil {
|
||||
menu.bgCancel()
|
||||
}
|
||||
if menu.eventCancel != nil {
|
||||
menu.eventCancel()
|
||||
}
|
||||
|
||||
os.Remove(menu.notificationIcon.Name())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -12,7 +14,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ACLRow defines a rule that grants access by a set of users or groups to a set
|
||||
@@ -20,7 +21,6 @@ import (
|
||||
// Only one of Src/Dst or Users/Ports may be specified.
|
||||
type ACLRow struct {
|
||||
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||
Proto string `json:"proto,omitempty"` // protocol
|
||||
Users []string `json:"users,omitempty"` // old name for src
|
||||
Ports []string `json:"ports,omitempty"` // old name for dst
|
||||
Src []string `json:"src,omitempty"`
|
||||
@@ -33,23 +33,12 @@ type ACLRow struct {
|
||||
type ACLTest struct {
|
||||
Src string `json:"src,omitempty"` // source
|
||||
User string `json:"user,omitempty"` // old name for source
|
||||
Proto string `json:"proto,omitempty"` // protocol
|
||||
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
|
||||
}
|
||||
|
||||
// NodeAttrGrant defines additional string attributes that apply to specific devices.
|
||||
type NodeAttrGrant struct {
|
||||
// Target specifies which nodes the attributes apply to. The nodes can be a
|
||||
// tag (tag:server), user (alice@example.com), group (group:kids), or *.
|
||||
Target []string `json:"target,omitempty"`
|
||||
|
||||
// Attr are the attributes to set on Target(s).
|
||||
Attr []string `json:"attr,omitempty"`
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
@@ -57,7 +46,6 @@ type ACLDetails struct {
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
@@ -84,7 +72,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
}
|
||||
}()
|
||||
|
||||
path := c.BuildTailnetURL("acl")
|
||||
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
|
||||
@@ -98,7 +86,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
@@ -117,7 +105,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// 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/s/acl-format
|
||||
// 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.
|
||||
@@ -127,7 +115,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
}
|
||||
}()
|
||||
|
||||
path := c.BuildTailnetURL("acl", url.Values{"details": {"1"}})
|
||||
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
|
||||
@@ -139,7 +127,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, HandleErrorResponse(b, resp)
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
@@ -147,7 +135,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
Warnings []string `json:"warnings"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, fmt.Errorf("json.Unmarshal %q: %w", b, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acl = &ACLHuJSON{
|
||||
@@ -164,14 +152,8 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||
// JavaScript client to be rendered in the HTML.
|
||||
type ACLTestFailureSummary struct {
|
||||
// User is the source ("src") value of the ACL test that failed.
|
||||
// The name "user" is a legacy holdover from the original naming and
|
||||
// is kept for compatibility but it may also contain any value
|
||||
// that's valid in a ACL test "src" field.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
|
||||
@@ -185,7 +167,7 @@ func (e ACLTestError) Error() string {
|
||||
}
|
||||
|
||||
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
|
||||
path := c.BuildTailnetURL("acl")
|
||||
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
|
||||
@@ -289,17 +271,6 @@ type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
// Via is the list of targets through which Users can access Ports.
|
||||
// See https://tailscale.com/kb/1378/via for more information.
|
||||
Via []string `json:"via,omitempty"`
|
||||
|
||||
// Postures is a list of posture policies that are
|
||||
// associated with this match. The rules can be looked
|
||||
// up in the ACLPreviewResponse parent struct.
|
||||
// The source of the list is from srcPosture on
|
||||
// an ACL or Grant rule:
|
||||
// https://tailscale.com/kb/1288/device-posture#posture-conditions
|
||||
Postures []string `json:"postures"`
|
||||
}
|
||||
|
||||
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||
@@ -307,12 +278,6 @@ 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.
|
||||
|
||||
// Postures is a map of postures and associated rules that apply
|
||||
// to this preview.
|
||||
// For more details about the posture mapping, see:
|
||||
// https://tailscale.com/kb/1288/device-posture#postures
|
||||
Postures map[string][]string `json:"postures,omitempty"`
|
||||
}
|
||||
|
||||
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||
@@ -320,16 +285,10 @@ 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
|
||||
|
||||
// Postures is a map of postures and associated rules that apply
|
||||
// to this preview.
|
||||
// For more details about the posture mapping, see:
|
||||
// https://tailscale.com/kb/1288/device-posture#postures
|
||||
Postures map[string][]string `json:"postures,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
path := c.BuildTailnetURL("acl", "preview")
|
||||
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
|
||||
@@ -351,7 +310,7 @@ func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, preview
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
return nil, err
|
||||
@@ -383,9 +342,8 @@ func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (r
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -412,9 +370,8 @@ func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -438,9 +395,8 @@ func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, use
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -464,9 +420,8 @@ func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, i
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -483,13 +438,13 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []ACLTest{{User: source, Allow: []string{dest}}}
|
||||
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
|
||||
postData, err := json.Marshal(tests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := c.BuildTailnetURL("acl", "validate")
|
||||
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
|
||||
@@ -504,7 +459,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("control api responded with %d status code", resp.StatusCode)
|
||||
return nil, fmt.Errorf("control api responsed with %d status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The test ran without fail
|
||||
|
||||
@@ -1,44 +1,19 @@
|
||||
// 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 and control plane API.
|
||||
package apitype
|
||||
|
||||
import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/util/ctxkey"
|
||||
)
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
|
||||
// RequestReasonHeader is the header used to pass justification for a LocalAPI request,
|
||||
// such as when a user wants to perform an action they don't have permission for,
|
||||
// and a policy allows it with justification. As of 2025-01-29, it is only used to
|
||||
// allow a user to disconnect Tailscale when the "always-on" mode is enabled.
|
||||
//
|
||||
// The header value is base64-encoded using the standard encoding defined in RFC 4648.
|
||||
//
|
||||
// See tailscale/corp#26146.
|
||||
const RequestReasonHeader = "X-Tailscale-Reason"
|
||||
|
||||
// RequestReasonKey is the context key used to pass the request reason
|
||||
// when making a LocalAPI request via [local.Client].
|
||||
// It's value is a raw string. An empty string means no reason was provided.
|
||||
//
|
||||
// See tailscale/corp#26146.
|
||||
var RequestReasonKey = ctxkey.New(RequestReasonHeader, "")
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
|
||||
// In successful whois responses, Node and UserProfile are never nil.
|
||||
type WhoIsResponse struct {
|
||||
Node *tailcfg.Node
|
||||
UserProfile *tailcfg.UserProfile
|
||||
|
||||
// CapMap is a map of capabilities to their values.
|
||||
// See tailcfg.PeerCapMap and tailcfg.PeerCapability for details.
|
||||
CapMap tailcfg.PeerCapMap
|
||||
// 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
|
||||
@@ -46,7 +21,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
|
||||
}
|
||||
@@ -55,42 +30,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
|
||||
}
|
||||
|
||||
// ReloadConfigResponse is the response to a LocalAPI reload-config request.
|
||||
//
|
||||
// There are three possible outcomes: (false, "") if no config mode in use,
|
||||
// (true, "") on success, or (false, "error message") on failure.
|
||||
type ReloadConfigResponse struct {
|
||||
Reloaded bool // whether the config was reloaded
|
||||
Err string // any error message
|
||||
}
|
||||
|
||||
// ExitNodeSuggestionResponse is the response to a LocalAPI suggest-exit-node GET request.
|
||||
// It returns the StableNodeID, name, and location of a suggested exit node for the client making the request.
|
||||
type ExitNodeSuggestionResponse struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Name string
|
||||
Location tailcfg.LocationView `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
|
||||
// into the CLI.
|
||||
type DNSOSConfig struct {
|
||||
Nameservers []string
|
||||
SearchDomains []string
|
||||
MatchDomains []string
|
||||
}
|
||||
|
||||
// DNSQueryResponse is the response to a DNS query request sent via LocalAPI.
|
||||
type DNSQueryResponse struct {
|
||||
// Bytes is the raw DNS response bytes.
|
||||
Bytes []byte
|
||||
// Resolvers is the list of resolvers that the forwarder deemed able to resolve the query.
|
||||
Resolvers []*dnstype.Resolver
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
|
||||
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"`
|
||||
TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
|
||||
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"`
|
||||
PerDomain bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
type DNSResolver struct {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -10,9 +12,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
@@ -40,23 +42,21 @@ type Device struct {
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
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"`
|
||||
Tags []string `json:"tags"`
|
||||
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"`
|
||||
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
|
||||
@@ -73,24 +73,6 @@ type Device struct {
|
||||
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
|
||||
|
||||
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
|
||||
|
||||
// PostureIdentity contains extra identifiers collected from the device when
|
||||
// the tailnet has the device posture identification features enabled. If
|
||||
// Tailscale have attempted to collect this from the device but it has not
|
||||
// opted in, PostureIdentity will have Disabled=true.
|
||||
PostureIdentity *DevicePostureIdentity `json:"postureIdentity"`
|
||||
|
||||
// TailnetLockKey is the tailnet lock public key of the node as a hex string.
|
||||
TailnetLockKey string `json:"tailnetLockKey,omitempty"`
|
||||
|
||||
// TailnetLockErr indicates an issue with the tailnet lock node-key signature
|
||||
// on this device. This field is only populated when tailnet lock is enabled.
|
||||
TailnetLockErr string `json:"tailnetLockError,omitempty"`
|
||||
}
|
||||
|
||||
type DevicePostureIdentity struct {
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
SerialNumbers []string `json:"serialNumbers,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceFieldsOpts determines which fields should be returned in the response.
|
||||
@@ -138,7 +120,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL
|
||||
}
|
||||
}()
|
||||
|
||||
path := c.BuildTailnetURL("devices")
|
||||
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
|
||||
@@ -156,7 +138,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var devices GetDevicesResponse
|
||||
@@ -195,7 +177,7 @@ func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFiel
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &device)
|
||||
@@ -222,33 +204,18 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
|
||||
|
||||
// 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 handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeDevice marks a device as authorized.
|
||||
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
|
||||
return c.SetAuthorized(ctx, deviceID, true)
|
||||
}
|
||||
|
||||
// SetAuthorized marks a device as authorized or not.
|
||||
func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized bool) error {
|
||||
params := &struct {
|
||||
Authorized bool `json:"authorized"`
|
||||
}{Authorized: authorized}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -260,7 +227,7 @@ func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized
|
||||
// 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 handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -288,7 +255,7 @@ func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) er
|
||||
// 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 handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -44,7 +46,7 @@ type DNSPreferences struct {
|
||||
}
|
||||
|
||||
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
path := c.BuildTailnetURL("dns", endpoint)
|
||||
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
|
||||
@@ -57,14 +59,14 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
|
||||
path := c.BuildTailnetURL("dns", endpoint)
|
||||
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
|
||||
@@ -84,7 +86,7 @@ func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData a
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, 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.
|
||||
|
||||
// The servetls program shows how to run an HTTPS server
|
||||
// using a Tailscale cert via LetsEncrypt.
|
||||
@@ -11,14 +12,13 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var lc local.Client
|
||||
s := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: lc.GetCertificate,
|
||||
GetCertificate: tailscale.GetCertificate,
|
||||
},
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
|
||||
|
||||
@@ -1,166 +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 := c.BuildTailnetURL("keys")
|
||||
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. It returns the secret key itself, which cannot be retrieved again
|
||||
// later, and the key metadata.
|
||||
//
|
||||
// To create a key with a specific expiry, use CreateKeyWithExpiry.
|
||||
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
|
||||
return c.CreateKeyWithExpiry(ctx, caps, 0)
|
||||
}
|
||||
|
||||
// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
|
||||
//
|
||||
// The time is truncated to a whole number of seconds. If zero, that means no expiration.
|
||||
func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
|
||||
|
||||
// convert expirySeconds to an int64 (seconds)
|
||||
expirySeconds := int64(expiry.Seconds())
|
||||
if expirySeconds < 0 {
|
||||
return "", nil, fmt.Errorf("expiry must be positive")
|
||||
}
|
||||
if expirySeconds == 0 && expiry != 0 {
|
||||
return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
|
||||
}
|
||||
|
||||
keyRequest := struct {
|
||||
Capabilities KeyCapabilities `json:"capabilities"`
|
||||
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
|
||||
}{caps, int64(expirySeconds)}
|
||||
bs, err := json.Marshal(keyRequest)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
path := c.BuildTailnetURL("keys")
|
||||
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 := c.BuildTailnetURL("keys", 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 := c.BuildTailnetURL("keys", 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
|
||||
}
|
||||
747
client/tailscale/localclient.go
Normal file
@@ -0,0 +1,747 @@
|
||||
// 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
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
// package-level functions.
|
||||
var defaultLocalClient LocalClient
|
||||
|
||||
// LocalClient is a client to Tailscale's "local API", communicating with the
|
||||
// Tailscale daemon on the local machine. Its API is not necessarily stable and
|
||||
// subject to changes between releases. Some API calls have stricter
|
||||
// compatibility guarantees, once they've been widely adopted. See method docs
|
||||
// for details.
|
||||
//
|
||||
// Its zero value is valid to use.
|
||||
//
|
||||
// Any exported fields should be set before using methods on the type
|
||||
// and not changed thereafter.
|
||||
type LocalClient struct {
|
||||
// Dial optionally specifies an alternate func that connects to the local
|
||||
// machine's tailscaled or equivalent. If nil, a default is used.
|
||||
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// Socket specifies an alternate path to the local Tailscale socket.
|
||||
// If empty, a platform-specific default is used.
|
||||
Socket string
|
||||
|
||||
// UseSocketOnly, if true, tries to only connect to tailscaled via the
|
||||
// Unix socket and not via fallback mechanisms as done on macOS when
|
||||
// connecting to the GUI client variants.
|
||||
UseSocketOnly bool
|
||||
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// It's lazily initialized on first use.
|
||||
tsClient *http.Client
|
||||
tsClientOnce sync.Once
|
||||
}
|
||||
|
||||
func (lc *LocalClient) socket() string {
|
||||
if lc.Socket != "" {
|
||||
return lc.Socket
|
||||
}
|
||||
return paths.DefaultTailscaledSocket()
|
||||
}
|
||||
|
||||
func (lc *LocalClient) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if lc.Dial != nil {
|
||||
return lc.Dial
|
||||
}
|
||||
return lc.defaultDialer
|
||||
}
|
||||
|
||||
func (lc *LocalClient) defaultDialer(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 !lc.UseSocketOnly {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
s := safesocket.DefaultConnectionStrategy(lc.socket())
|
||||
// The user provided a non-default tailscaled socket address.
|
||||
// Connect only to exactly what they provided.
|
||||
s.UseFallback(false)
|
||||
return safesocket.Connect(s)
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
//
|
||||
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// DoLocalRequest may mutate the request to add Authorization headers.
|
||||
func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||||
lc.tsClientOnce.Do(func() {
|
||||
lc.tsClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: lc.dialer(),
|
||||
},
|
||||
}
|
||||
})
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
return lc.tsClient.Do(req)
|
||||
}
|
||||
|
||||
func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
|
||||
res, err := lc.DoLocalRequest(req)
|
||||
if err == nil {
|
||||
if server := res.Header.Get("Tailscale-Version"); server != "" && server != ipn.IPCVersion() && onVersionMismatch != nil {
|
||||
onVersionMismatch(ipn.IPCVersion(), server)
|
||||
}
|
||||
if res.StatusCode == 403 {
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
if ue, ok := err.(*url.Error); ok {
|
||||
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
|
||||
path := req.URL.Path
|
||||
pathPrefix, _, _ := strings.Cut(path, "?")
|
||||
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type errorJSON struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// AccessDeniedError is an error due to permissions.
|
||||
type AccessDeniedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
|
||||
func (e *AccessDeniedError) Unwrap() error { return e.err }
|
||||
|
||||
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
|
||||
func IsAccessDeniedError(err error) bool {
|
||||
var ae *AccessDeniedError
|
||||
return errors.As(err, &ae)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func errorMessageFromBody(body []byte) string {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return j.Error
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
var onVersionMismatch func(clientVer, serverVer string)
|
||||
|
||||
// SetVersionMismatchHandler sets f as the version mismatch handler
|
||||
// to be called when the client (the current process) has a version
|
||||
// number that doesn't match the server's declared version.
|
||||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
onVersionMismatch = f
|
||||
}
|
||||
|
||||
func (lc *LocalClient) 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, err
|
||||
}
|
||||
res, err := lc.doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
slurp, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, bestError(err, slurp)
|
||||
}
|
||||
return slurp, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return lc.send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
//
|
||||
// Deprecated: use LocalClient.WhoIs.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
return defaultLocalClient.WhoIs(ctx, remoteAddr)
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.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
|
||||
}
|
||||
|
||||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||||
func (lc *LocalClient) Goroutines(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/goroutines")
|
||||
}
|
||||
|
||||
// DaemonMetrics returns the Tailscale daemon's metrics in
|
||||
// the Prometheus text exposition format.
|
||||
func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// Profile returns a pprof profile of the Tailscale daemon.
|
||||
func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||||
var secArg string
|
||||
if sec < 0 || sec > 300 {
|
||||
return nil, errors.New("duration out of range")
|
||||
}
|
||||
if sec != 0 || pprofType == "profile" {
|
||||
secArg = fmt.Sprint(sec)
|
||||
}
|
||||
return lc.get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// DebugAction invokes a debug action, such as "rebind" or "restun".
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return defaultLocalClient.Status(ctx)
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func (lc *LocalClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return lc.status(ctx, "")
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return defaultLocalClient.StatusWithoutPeers(ctx)
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func (lc *LocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return lc.status(ctx, "?peers=false")
|
||||
}
|
||||
|
||||
func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
||||
body, err := lc.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
|
||||
}
|
||||
|
||||
// IDToken is a request to get an OIDC ID token for an audience.
|
||||
// The token can be presented to any resource provider which offers OIDC
|
||||
// Federation.
|
||||
func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := new(tailcfg.TokenResponse)
|
||||
if err := json.Unmarshal(body, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
body, err := lc.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 (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||||
_, err := lc.send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (lc *LocalClient) 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 := lc.doLocalRequestNiceError(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, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||||
}
|
||||
return res.Body, res.ContentLength, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
|
||||
body, err := lc.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
|
||||
}
|
||||
|
||||
// PushFile sends Taildrop file r to target.
|
||||
//
|
||||
// A size of -1 means unknown.
|
||||
// The name parameter is the original filename, not escaped.
|
||||
func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size != -1 {
|
||||
req.ContentLength = size
|
||||
}
|
||||
res, err := lc.doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode == 200 {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
return nil
|
||||
}
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return bestError(fmt.Errorf("%s: %s", res.Status, all), all)
|
||||
}
|
||||
|
||||
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
|
||||
// machine is properly configured to forward IP packets as a subnet router
|
||||
// or exit node.
|
||||
func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// CheckPrefs validates the provided preferences, without making any changes.
|
||||
//
|
||||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||||
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
|
||||
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
|
||||
// EditPrefs is not necessary.
|
||||
func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
|
||||
pj, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj))
|
||||
return err
|
||||
}
|
||||
|
||||
func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
body, err := lc.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 (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
mpj, err := json.Marshal(mp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.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 (lc *LocalClient) Logout(ctx context.Context) error {
|
||||
_, err := lc.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 (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
|
||||
v := url.Values{}
|
||||
v.Set("name", name)
|
||||
v.Set("value", value)
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DialTCP connects to the host's port via Tailscale.
|
||||
//
|
||||
// The host may be a base DNS name (resolved from the netmap inside
|
||||
// tailscaled), a FQDN, or an IP address.
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
|
||||
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||||
connCh := make(chan net.Conn, 1)
|
||||
trace := httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
connCh <- info.Conn
|
||||
},
|
||||
}
|
||||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header = http.Header{
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
}
|
||||
res, err := lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
|
||||
}
|
||||
// From here on, the underlying net.Conn is ours to use, but there
|
||||
// is still a read buffer attached to it within resp.Body. So, we
|
||||
// must direct I/O through resp.Body, but we can still use the
|
||||
// underlying net.Conn for stuff like deadlines.
|
||||
var switchedConn net.Conn
|
||||
select {
|
||||
case switchedConn = <-connCh:
|
||||
default:
|
||||
}
|
||||
if switchedConn == nil {
|
||||
res.Body.Close()
|
||||
return nil, fmt.Errorf("httptrace didn't provide a connection")
|
||||
}
|
||||
rwc, ok := res.Body.(io.ReadWriteCloser)
|
||||
if !ok {
|
||||
res.Body.Close()
|
||||
return nil, errors.New("http Transport did not provide a writable body")
|
||||
}
|
||||
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
|
||||
}
|
||||
|
||||
// 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 (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
var derpMap tailcfg.DERPMap
|
||||
res, err := lc.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
|
||||
}
|
||||
|
||||
// CertPair returns a cert and private key for the provided DNS domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// Deprecated: use LocalClient.CertPair.
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return defaultLocalClient.CertPair(ctx, domain)
|
||||
}
|
||||
|
||||
// CertPair returns a cert and private key for the provided DNS domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// with ?type=pair, the response PEM is first the one private
|
||||
// key PEM block, then the cert PEM blocks.
|
||||
i := mem.Index(mem.B(res), mem.S("--\n--"))
|
||||
if i == -1 {
|
||||
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
|
||||
}
|
||||
i += len("--\n")
|
||||
keyPEM, certPEM = res[:i], res[i:]
|
||||
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
|
||||
return nil, nil, fmt.Errorf("unexpected output: key in cert")
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// It's the right signature to use as the value of
|
||||
// tls.Config.GetCertificate.
|
||||
//
|
||||
// Deprecated: use LocalClient.GetCertificate.
|
||||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return defaultLocalClient.GetCertificate(hi)
|
||||
}
|
||||
|
||||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// It's the right signature to use as the value of
|
||||
// tls.Config.GetCertificate.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi == nil || hi.ServerName == "" {
|
||||
return nil, errors.New("no SNI ServerName")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
name := hi.ServerName
|
||||
if !strings.Contains(name, ".") {
|
||||
if v, ok := lc.ExpandSNIName(ctx, name); ok {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
certPEM, keyPEM, err := lc.CertPair(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
//
|
||||
// Deprecated: use LocalClient.ExpandSNIName.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
return defaultLocalClient.ExpandSNIName(ctx, name)
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
st, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, d := range st.CertDomains {
|
||||
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Ping sends a ping of the provided type to the provided IP and waits
|
||||
// for its response.
|
||||
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
|
||||
v := url.Values{}
|
||||
v.Set("ip", ip.String())
|
||||
v.Set("type", string(pingtype))
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
pr := new(ipnstate.PingResult)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
pr := new(ipnstate.NetworkLockStatus)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// NetworkLockInit initializes the tailnet key authority.
|
||||
func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key) (*ipnstate.NetworkLockStatus, error) {
|
||||
var b bytes.Buffer
|
||||
type initRequest struct {
|
||||
Keys []tka.Key
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/init", 200, &b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
|
||||
pr := new(ipnstate.NetworkLockStatus)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// tailscaledConnectHint gives a little thing about why tailscaled (or
|
||||
// platform equivalent) is not answering localapi connections.
|
||||
//
|
||||
// It ends in a punctuation. See caller.
|
||||
func tailscaledConnectHint() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
// TODO(bradfitz): flesh this out
|
||||
return "not running?"
|
||||
}
|
||||
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
|
||||
if err != nil {
|
||||
return "not running?"
|
||||
}
|
||||
// Parse:
|
||||
// LoadState=loaded
|
||||
// ActiveState=inactive
|
||||
// SubState=dead
|
||||
st := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
st[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if st["LoadState"] == "loaded" &&
|
||||
(st["SubState"] != "running" || st["ActiveState"] != "active") {
|
||||
return "systemd tailscaled.service not running."
|
||||
}
|
||||
return "not running?"
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
// ErrPeerNotFound is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
var ErrPeerNotFound = local.ErrPeerNotFound
|
||||
|
||||
// LocalClient is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type LocalClient = local.Client
|
||||
|
||||
// IPNBusWatcher is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type IPNBusWatcher = local.IPNBusWatcher
|
||||
|
||||
// BugReportOpts is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type BugReportOpts = local.BugReportOpts
|
||||
|
||||
// DebugPortMapOpts is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type DebugPortmapOpts = local.DebugPortmapOpts
|
||||
|
||||
// PingOpts is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type PingOpts = local.PingOpts
|
||||
|
||||
// GetCertificate is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return local.GetCertificate(hi)
|
||||
}
|
||||
|
||||
// SetVersionMismatchHandler is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
local.SetVersionMismatchHandler(f)
|
||||
}
|
||||
|
||||
// IsAccessDeniedError is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func IsAccessDeniedError(err error) bool {
|
||||
return local.IsAccessDeniedError(err)
|
||||
}
|
||||
|
||||
// IsPreconditionsFailedError is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func IsPreconditionsFailedError(err error) bool {
|
||||
return local.IsPreconditionsFailedError(err)
|
||||
}
|
||||
|
||||
// WhoIs is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
return local.WhoIs(ctx, remoteAddr)
|
||||
}
|
||||
|
||||
// Status is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return local.Status(ctx)
|
||||
}
|
||||
|
||||
// StatusWithoutPeers is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return local.StatusWithoutPeers(ctx)
|
||||
}
|
||||
|
||||
// CertPair is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return local.CertPair(ctx, domain)
|
||||
}
|
||||
|
||||
// ExpandSNIName is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
return local.ExpandSNIName(ctx, name)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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.23
|
||||
//go:build !go1.19
|
||||
// +build !go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_23_to_compile_Tailscale()
|
||||
you_need_Go_1_19_to_compile_Tailscale()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -44,7 +46,7 @@ func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, e
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var sr Routes
|
||||
@@ -84,7 +86,7 @@ func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip
|
||||
// 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 nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var srr *Routes
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -9,8 +11,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
|
||||
@@ -21,8 +22,8 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
|
||||
}
|
||||
}()
|
||||
|
||||
path := c.BuildTailnetURL("tailnet")
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return HandleErrorResponse(b, resp)
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// Copyright (c) 2022 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
|
||||
// +build go1.19
|
||||
|
||||
// Package tailscale contains a Go client for the Tailscale control plane API.
|
||||
// Package tailscale contains Go clients for the Tailscale Local API and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// This package is only intended for internal and transitional use.
|
||||
//
|
||||
// Deprecated: the official control plane client is available at
|
||||
// tailscale.com/client/tailscale/v2.
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -17,12 +18,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
)
|
||||
|
||||
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||
// for now. This package is being replaced by tailscale.com/client/tailscale/v2.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
@@ -36,8 +38,6 @@ const maxReadSize = 10 << 20
|
||||
//
|
||||
// Use NewClient to instantiate one. Exported fields should be set before
|
||||
// the client is used and not changed thereafter.
|
||||
//
|
||||
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
|
||||
type Client struct {
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
@@ -53,9 +53,6 @@ type Client struct {
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// UserAgent optionally specifies an alternate User-Agent header
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
@@ -65,46 +62,6 @@ func (c *Client) httpClient() *http.Client {
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// BuildURL builds a url to http(s)://<apiserver>/api/v2/<slash-separated-pathElements>
|
||||
// using the given pathElements. It url escapes each path element, so the
|
||||
// caller doesn't need to worry about that. The last item of pathElements can
|
||||
// be of type url.Values to add a query string to the URL.
|
||||
//
|
||||
// For example, BuildURL(devices, 5) with the default server URL would result in
|
||||
// https://api.tailscale.com/api/v2/devices/5.
|
||||
func (c *Client) BuildURL(pathElements ...any) string {
|
||||
elem := make([]string, 1, len(pathElements)+1)
|
||||
elem[0] = "/api/v2"
|
||||
var query string
|
||||
for i, pathElement := range pathElements {
|
||||
if uv, ok := pathElement.(url.Values); ok && i == len(pathElements)-1 {
|
||||
query = uv.Encode()
|
||||
} else {
|
||||
elem = append(elem, url.PathEscape(fmt.Sprint(pathElement)))
|
||||
}
|
||||
}
|
||||
url := c.baseURL() + path.Join(elem...)
|
||||
if query != "" {
|
||||
url += "?" + query
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// BuildTailnetURL builds a url to http(s)://<apiserver>/api/v2/tailnet/<tailnet>/<slash-separated-pathElements>
|
||||
// using the given pathElements. It url escapes each path element, so the
|
||||
// caller doesn't need to worry about that. The last item of pathElements can
|
||||
// be of type url.Values to add a query string to the URL.
|
||||
//
|
||||
// For example, BuildTailnetURL(policy, validate) with the default server URL and a tailnet of "example.com"
|
||||
// would result in https://api.tailscale.com/api/v2/tailnet/example.com/policy/validate.
|
||||
func (c *Client) BuildTailnetURL(pathElements ...any) string {
|
||||
allElements := make([]any, 2, len(pathElements)+2)
|
||||
allElements[0] = "tailnet"
|
||||
allElements[1] = c.tailnet
|
||||
allElements = append(allElements, pathElements...)
|
||||
return c.BuildURL(allElements...)
|
||||
}
|
||||
|
||||
func (c *Client) baseURL() string {
|
||||
if c.BaseURL != "" {
|
||||
return c.BaseURL
|
||||
@@ -140,13 +97,10 @@ func (c *Client) setAuth(r *http.Request) {
|
||||
// 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.
|
||||
//
|
||||
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
UserAgent: "tailscale-client-oss",
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,16 +112,17 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
if c.UserAgent != "" {
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// sendRequest add the authenication 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) {
|
||||
resp, err := c.Do(req)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
@@ -192,14 +147,12 @@ func (e ErrResponse) Error() string {
|
||||
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// HandleErrorResponse decodes the error message from the server and returns
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
//
|
||||
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
|
||||
func HandleErrorResponse(b []byte, resp *http.Response) error {
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal %q: %w", b, err)
|
||||
return err
|
||||
}
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientBuildURL(t *testing.T) {
|
||||
c := Client{BaseURL: "http://127.0.0.1:1234"}
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
elements []any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
desc: "single-element",
|
||||
elements: []any{"devices"},
|
||||
want: "http://127.0.0.1:1234/api/v2/devices",
|
||||
},
|
||||
{
|
||||
desc: "multiple-elements",
|
||||
elements: []any{"tailnet", "example.com"},
|
||||
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com",
|
||||
},
|
||||
{
|
||||
desc: "escape-element",
|
||||
elements: []any{"tailnet", "example dot com?foo=bar"},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example%20dot%20com%3Ffoo=bar`,
|
||||
},
|
||||
{
|
||||
desc: "url.Values",
|
||||
elements: []any{"tailnet", "example.com", "acl", url.Values{"details": {"1"}}},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/acl?details=1`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := c.BuildURL(tt.elements...)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBuildTailnetURL(t *testing.T) {
|
||||
c := Client{
|
||||
BaseURL: "http://127.0.0.1:1234",
|
||||
tailnet: "example.com",
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
desc string
|
||||
elements []any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
desc: "single-element",
|
||||
elements: []any{"devices"},
|
||||
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com/devices",
|
||||
},
|
||||
{
|
||||
desc: "multiple-elements",
|
||||
elements: []any{"devices", 123},
|
||||
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com/devices/123",
|
||||
},
|
||||
{
|
||||
desc: "escape-element",
|
||||
elements: []any{"foo bar?baz=qux"},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/foo%20bar%3Fbaz=qux`,
|
||||
},
|
||||
{
|
||||
desc: "url.Values",
|
||||
elements: []any{"acl", url.Values{"details": {"1"}}},
|
||||
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/acl?details=1`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := c.BuildTailnetURL(tt.elements...)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
prebuilt "github.com/tailscale/web-client-prebuilt"
|
||||
)
|
||||
|
||||
var start = time.Now()
|
||||
|
||||
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
||||
if devMode {
|
||||
// When in dev mode, proxy asset requests to the Vite dev server.
|
||||
cleanup := startDevServer()
|
||||
return devServerProxy(), cleanup
|
||||
}
|
||||
|
||||
fsys := prebuilt.FS()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
f, err := openPrecompressedFile(w, r, path, fsys)
|
||||
if err != nil {
|
||||
// Rewrite request to just fetch index.html and let
|
||||
// the frontend router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
path = "index.html"
|
||||
f, err = openPrecompressedFile(w, r, path, fsys)
|
||||
}
|
||||
if f == nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// fs.File does not claim to implement Seeker, but in practice it does.
|
||||
fSeeker, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.Error(w, "Not seekable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
// Aggressively cache static assets, since we cache-bust our assets with
|
||||
// hashed filenames.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31535996")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, path, start, fSeeker)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
|
||||
if f, err := fs.Open(path + ".gz"); err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
return f, nil
|
||||
}
|
||||
return fs.Open(path) // fallback
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
// and serving of web client JS and CSS resources.
|
||||
func startDevServer() (cleanup func()) {
|
||||
root := gitRootDir()
|
||||
webClientPath := filepath.Join(root, "client", "web")
|
||||
|
||||
yarn := filepath.Join(root, "tool", "yarn")
|
||||
node := filepath.Join(root, "tool", "node")
|
||||
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
|
||||
|
||||
log.Printf("installing JavaScript deps using %s...", yarn)
|
||||
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
|
||||
}
|
||||
log.Printf("starting JavaScript dev server...")
|
||||
cmd := exec.Command(node, vite)
|
||||
cmd.Dir = webClientPath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("Starting JS dev server: %v", err)
|
||||
}
|
||||
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
|
||||
return func() {
|
||||
cmd.Process.Signal(os.Interrupt)
|
||||
err := cmd.Wait()
|
||||
log.Printf("JavaScript dev server exited: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// devServerProxy returns a reverse proxy to the vite dev server.
|
||||
func devServerProxy() *httputil.ReverseProxy {
|
||||
// We use Vite to develop on the web client.
|
||||
// Vite starts up its own local server for development,
|
||||
// which we proxy requests to from Server.ServeHTTP.
|
||||
// Here we set up the proxy to Vite's server.
|
||||
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("The web client development server isn't running. " +
|
||||
"Run `./tool/yarn --cwd client/web start` from " +
|
||||
"the repo root to start the development server."))
|
||||
w.Write([]byte("\n\nError: " + err.Error()))
|
||||
}
|
||||
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
|
||||
devProxy := httputil.NewSingleHostReverseProxy(viteTarget)
|
||||
devProxy.ErrorHandler = handleErr
|
||||
return devProxy
|
||||
}
|
||||
|
||||
func gitRootDir() string {
|
||||
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(top))
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
// browserSession holds data about a user's browser session
|
||||
// on the full management web client.
|
||||
type browserSession struct {
|
||||
// ID is the unique identifier for the session.
|
||||
// It is passed in the user's "TS-Web-Session" browser cookie.
|
||||
ID string
|
||||
SrcNode tailcfg.NodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthID string // from tailcfg.WebClientAuthResponse
|
||||
AuthURL string // from tailcfg.WebClientAuthResponse
|
||||
Created time.Time
|
||||
Authenticated bool
|
||||
}
|
||||
|
||||
// isAuthorized reports true if the given session is authorized
|
||||
// to be used by its associated user to access the full management
|
||||
// web client.
|
||||
//
|
||||
// isAuthorized is true only when s.Authenticated is true (i.e.
|
||||
// the user has authenticated the session) and the session is not
|
||||
// expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isAuthorized(now time.Time) bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case !s.Authenticated:
|
||||
return false // awaiting auth
|
||||
case s.isExpired(now):
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isExpired(now time.Time) bool {
|
||||
return !s.Created.IsZero() && now.After(s.expires())
|
||||
}
|
||||
|
||||
// expires reports when the given session expires.
|
||||
func (s *browserSession) expires() time.Time {
|
||||
return s.Created.Add(sessionCookieExpiry)
|
||||
}
|
||||
|
||||
var (
|
||||
errNoSession = errors.New("no-browser-session")
|
||||
errNotUsingTailscale = errors.New("not-using-tailscale")
|
||||
errTaggedRemoteSource = errors.New("tagged-remote-source")
|
||||
errTaggedLocalSource = errors.New("tagged-local-source")
|
||||
errNotOwner = errors.New("not-owner")
|
||||
)
|
||||
|
||||
// getSession retrieves the browser session associated with the request,
|
||||
// if one exists.
|
||||
//
|
||||
// An error is returned in any of the following cases:
|
||||
//
|
||||
// - (errNotUsingTailscale) The request was not made over tailscale.
|
||||
//
|
||||
// - (errNoSession) The request does not have a session.
|
||||
//
|
||||
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
|
||||
// Users must use their own user-owned devices to manage other nodes'
|
||||
// web clients.
|
||||
//
|
||||
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
|
||||
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
|
||||
// access to web clients.
|
||||
//
|
||||
// - (errNotOwner) The source is not the owner of this client (if the
|
||||
// client is user-owned). Only the owner is allowed to manage the
|
||||
// node via the web client.
|
||||
//
|
||||
// If no error is returned, the browserSession is always non-nil.
|
||||
// getTailscaleBrowserSession does not check whether the session has been
|
||||
// authorized by the user. Callers can use browserSession.isAuthorized.
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
|
||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case whoIsErr != nil:
|
||||
return nil, nil, status, errNotUsingTailscale
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, nil, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, status, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, status, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, status, errNotOwner
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, status, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, status, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, status, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, whoIs, status, errNoSession
|
||||
} else if session.isExpired(s.timeNow()) {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, status, errNoSession
|
||||
}
|
||||
return session, whoIs, status, nil
|
||||
}
|
||||
|
||||
// newSession creates a new session associated with the given source user/node,
|
||||
// and stores it back to the session cache. Creating of a new session includes
|
||||
// generating a new auth URL from the control server.
|
||||
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: src.Node.ID,
|
||||
SrcUser: src.UserProfile.ID,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
|
||||
if s.controlSupportsCheckMode(ctx) {
|
||||
// control supports check mode, so get a new auth URL and return.
|
||||
a, err := s.newAuthURL(ctx, src.Node.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.AuthID = a.ID
|
||||
session.AuthURL = a.URL
|
||||
} else {
|
||||
// control does not support check mode, so there is no additional auth we can do.
|
||||
session.Authenticated = true
|
||||
}
|
||||
|
||||
s.browserSessions.Store(sid, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
|
||||
// We assume that only "tailscale.com" control servers support check mode.
|
||||
// This allows the web client to be used with non-standard control servers.
|
||||
// If an error occurs getting the control URL, this method returns true to fail closed.
|
||||
//
|
||||
// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
|
||||
func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
controlURL, err := url.Parse(prefs.ControlURLOrDefault())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
|
||||
}
|
||||
|
||||
// awaitUserAuth blocks until the given session auth has been completed
|
||||
// by the user on the control server, then updates the session cache upon
|
||||
// completion. An error is returned if control auth failed for any reason.
|
||||
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return nil // already authorized
|
||||
}
|
||||
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
|
||||
if err != nil {
|
||||
// Clean up the session. Doing this on any error from control
|
||||
// server to avoid the user getting stuck with a bad session
|
||||
// cookie.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return err
|
||||
}
|
||||
if a.Complete {
|
||||
session.Authenticated = a.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for range 5 {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
if _, ok := s.browserSessions.Load(cookie); !ok {
|
||||
return cookie, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
// peerCapabilities holds information about what a source
|
||||
// peer is allowed to edit via the web UI.
|
||||
//
|
||||
// map value is true if the peer can edit the given feature.
|
||||
// Only capFeatures included in validCaps will be included.
|
||||
type peerCapabilities map[capFeature]bool
|
||||
|
||||
// canEdit is true if the peerCapabilities grant edit access
|
||||
// to the given feature.
|
||||
func (p peerCapabilities) canEdit(feature capFeature) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p[capFeatureAll] {
|
||||
return true
|
||||
}
|
||||
return p[feature]
|
||||
}
|
||||
|
||||
// isEmpty is true if p is either nil or has no capabilities
|
||||
// with value true.
|
||||
func (p peerCapabilities) isEmpty() bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
for _, v := range p {
|
||||
if v == true {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type capFeature string
|
||||
|
||||
const (
|
||||
// The following values should not be edited.
|
||||
// New caps can be added, but existing ones should not be changed,
|
||||
// as these exact values are used by users in tailnet policy files.
|
||||
//
|
||||
// IMPORTANT: When adding a new cap, also update validCaps slice below.
|
||||
|
||||
capFeatureAll capFeature = "*" // grants peer management of all features
|
||||
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
|
||||
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
|
||||
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
|
||||
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
|
||||
)
|
||||
|
||||
// validCaps contains the list of valid capabilities used in the web client.
|
||||
// Any capabilities included in a peer's grants that do not fall into this
|
||||
// list will be ignored.
|
||||
var validCaps []capFeature = []capFeature{
|
||||
capFeatureAll,
|
||||
capFeatureSSH,
|
||||
capFeatureSubnets,
|
||||
capFeatureExitNodes,
|
||||
capFeatureAccount,
|
||||
}
|
||||
|
||||
type capRule struct {
|
||||
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
|
||||
}
|
||||
|
||||
// toPeerCapabilities parses out the web ui capabilities from the
|
||||
// given whois response.
|
||||
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
|
||||
if whois == nil || status == nil {
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
if whois.Node.IsTagged() {
|
||||
// We don't allow management *from* tagged nodes, so ignore caps.
|
||||
// The web client auth flow relies on having a true user identity
|
||||
// that can be verified through login.
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
|
||||
if !status.Self.IsTagged() {
|
||||
// User owned nodes are only ever manageable by the owner.
|
||||
if status.Self.UserID != whois.UserProfile.ID {
|
||||
return peerCapabilities{}, nil
|
||||
} else {
|
||||
return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
|
||||
}
|
||||
}
|
||||
|
||||
// For tagged nodes, we actually look at the granted capabilities.
|
||||
caps := peerCapabilities{}
|
||||
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
|
||||
}
|
||||
for _, c := range rules {
|
||||
for _, f := range c.CanEdit {
|
||||
cap := capFeature(strings.ToLower(f))
|
||||
if slices.Contains(validCaps, cap) {
|
||||
caps[cap] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<!doctype html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title>Tailscale</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
|
||||
<link rel="preload" as="font" href="./assets/Inter.var.latin-39e72c07.woff2" type="font/woff2" crossorigin />
|
||||
<script type="module" crossorigin src="./assets/index-fd4af382.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-218918fa.css">
|
||||
</head>
|
||||
<body class="px-2">
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.Tailscale) {
|
||||
const rootEl = document.createElement("p")
|
||||
rootEl.innerHTML = 'Tailscale was built without the web client. See <a href="https://github.com/tailscale/tailscale#building-the-web-client">Building the web client</a> for more information.'
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
<!doctype html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title>Tailscale</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||
<link rel="preload" as="font" href="/src/assets/fonts/Inter.var.latin.woff2" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
<body class="px-2">
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script>
|
||||
// if this script is changed, also change hash in web.go
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.Tailscale) {
|
||||
const rootEl = document.createElement("p")
|
||||
rootEl.innerHTML = 'Tailscale web interface is unavailable.';
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"name": "webclient",
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.20.4",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swr": "^2.2.4",
|
||||
"wouter": "^2.11.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.16.1",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.23.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-curly-quotes": "^1.0.4",
|
||||
"jsdom": "^23.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit && eslint 'src/**/*.{ts,tsx,js,jsx}'",
|
||||
"test": "vitest",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
],
|
||||
"plugins": [
|
||||
"curly-quotes",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"curly-quotes/no-straight-quotes": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error"
|
||||
},
|
||||
"settings": {
|
||||
"projectRoot": "client/web/package.json"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// qnap.go contains handlers and logic, such as authentication,
|
||||
// that is specific to running the web client on QNAP.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
|
||||
// are authorized to use the web client.
|
||||
// If the user is not authorized to use the client, an error is returned.
|
||||
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
|
||||
_, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
return false, errors.New("user is not an admin")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type qnapAuthResponse struct {
|
||||
AuthPassed int `xml:"authPassed"`
|
||||
IsAdmin int `xml:"isAdmin"`
|
||||
AuthSID string `xml:"authSid"`
|
||||
ErrorValue int `xml:"errorValue"`
|
||||
}
|
||||
|
||||
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
user, err := r.Cookie("NAS_USER")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token, err := r.Cookie("qtoken")
|
||||
if err == nil {
|
||||
return qnapAuthnQtoken(r, user.Value, token.Value)
|
||||
}
|
||||
sid, err := r.Cookie("NAS_SID")
|
||||
if err == nil {
|
||||
return qnapAuthnSid(r, user.Value, sid.Value)
|
||||
}
|
||||
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
||||
}
|
||||
|
||||
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
|
||||
// running based on the request URL. This is necessary because QNAP has so
|
||||
// many options, see https://github.com/tailscale/tailscale/issues/7108
|
||||
// and https://github.com/tailscale/tailscale/issues/6903
|
||||
func qnapAuthnURL(requestUrl string, query url.Values) string {
|
||||
in, err := url.Parse(requestUrl)
|
||||
scheme := ""
|
||||
host := ""
|
||||
if err != nil || in.Scheme == "" {
|
||||
log.Printf("Cannot parse QNAP login URL %v", err)
|
||||
|
||||
// try localhost and hope for the best
|
||||
scheme = "http"
|
||||
host = "localhost"
|
||||
} else {
|
||||
scheme = in.Scheme
|
||||
host = in.Host
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{token},
|
||||
"user": []string{user},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"sid": []string{sid},
|
||||
}
|
||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||
}
|
||||
|
||||
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
|
||||
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
|
||||
// SAN. See https://github.com/tailscale/tailscale/issues/6903
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
authResp := &qnapAuthResponse{}
|
||||
if err := xml.Unmarshal(out, authResp); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if authResp.AuthPassed == 0 {
|
||||
return "", nil, fmt.Errorf("not authenticated")
|
||||
}
|
||||
return user, authResp, nil
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback } from "react"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import { ExitNode, NodeData, SubnetRoute } from "src/types"
|
||||
import { assertNever } from "src/utils/util"
|
||||
import { MutatorOptions, SWRConfiguration, useSWRConfig } from "swr"
|
||||
import { noExitNode, runAsExitNode } from "./hooks/exit-nodes"
|
||||
|
||||
export const swrConfig: SWRConfiguration = {
|
||||
fetcher: (url: string) => apiFetch(url, "GET"),
|
||||
onError: (err, _) => console.error(err),
|
||||
}
|
||||
|
||||
type APIType =
|
||||
| { action: "up"; data: TailscaleUpData }
|
||||
| { action: "logout" }
|
||||
| { action: "new-auth-session"; data: AuthSessionNewData }
|
||||
| { action: "update-prefs"; data: LocalPrefsData }
|
||||
| { action: "update-routes"; data: SubnetRoute[] }
|
||||
| { action: "update-exit-node"; data: ExitNode }
|
||||
|
||||
/**
|
||||
* POST /api/up data
|
||||
*/
|
||||
type TailscaleUpData = {
|
||||
Reauthenticate?: boolean // force reauthentication
|
||||
ControlURL?: string
|
||||
AuthKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/session/new data
|
||||
*/
|
||||
type AuthSessionNewData = {
|
||||
authUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/local/v0/prefs data
|
||||
*/
|
||||
type LocalPrefsData = {
|
||||
RunSSHSet?: boolean
|
||||
RunSSH?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/routes data
|
||||
*/
|
||||
type RoutesData = {
|
||||
SetExitNode?: boolean
|
||||
SetRoutes?: boolean
|
||||
UseExitNode?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
AdvertiseRoutes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* useAPI hook returns an api handler that can execute api calls
|
||||
* throughout the web client UI.
|
||||
*/
|
||||
export function useAPI() {
|
||||
const toaster = useToaster()
|
||||
const { mutate } = useSWRConfig() // allows for global mutation
|
||||
|
||||
const handlePostError = useCallback(
|
||||
(toast?: string) => (err: Error) => {
|
||||
console.error(err)
|
||||
toast && toaster.show({ variant: "danger", message: toast })
|
||||
throw err
|
||||
},
|
||||
[toaster]
|
||||
)
|
||||
|
||||
/**
|
||||
* optimisticMutate wraps the SWR `mutate` function to apply some
|
||||
* type-awareness with the following behavior:
|
||||
*
|
||||
* 1. `optimisticData` update is applied immediately on FetchDataType
|
||||
* throughout the web client UI.
|
||||
*
|
||||
* 2. `fetch` data mutation runs.
|
||||
*
|
||||
* 3. On completion, FetchDataType is revalidated to exactly reflect the
|
||||
* updated server state.
|
||||
*
|
||||
* The `key` argument is the useSWR key associated with the MutateDataType.
|
||||
* All `useSWR(key)` consumers throughout the UI will see updates reflected.
|
||||
*/
|
||||
const optimisticMutate = useCallback(
|
||||
<MutateDataType, FetchDataType = any>(
|
||||
key: string,
|
||||
fetch: Promise<FetchDataType>,
|
||||
optimisticData: (current: MutateDataType) => MutateDataType,
|
||||
revalidate?: boolean // optionally specify whether to run final revalidation (step 3)
|
||||
): Promise<FetchDataType | undefined> => {
|
||||
const options: MutatorOptions = {
|
||||
/**
|
||||
* populateCache is meant for use when the remote request returns back
|
||||
* the updated data directly. i.e. When FetchDataType is the same as
|
||||
* MutateDataType. Most of our data manipulation requests return a 200
|
||||
* with empty data on success. We turn off populateCache so that the
|
||||
* cache only gets updated after completion of the remote reqeust when
|
||||
* the revalidation step runs.
|
||||
*/
|
||||
populateCache: false,
|
||||
optimisticData,
|
||||
revalidate: revalidate,
|
||||
}
|
||||
return mutate(key, fetch, options)
|
||||
},
|
||||
[mutate]
|
||||
)
|
||||
|
||||
const api = useCallback(
|
||||
(t: APIType) => {
|
||||
switch (t.action) {
|
||||
/**
|
||||
* "up" handles authenticating the machine to tailnet.
|
||||
*/
|
||||
case "up":
|
||||
return apiFetch<{ url?: string }>("/up", "POST", t.data)
|
||||
.then((d) => d.url && window.open(d.url, "_blank")) // "up" login step
|
||||
.then(() => incrementMetric("web_client_node_connect"))
|
||||
.then(() => mutate("/data"))
|
||||
.catch(handlePostError("Failed to login"))
|
||||
|
||||
/**
|
||||
* "logout" handles logging the node out of tailscale, effectively
|
||||
* expiring its node key.
|
||||
*/
|
||||
case "logout":
|
||||
// For logout, must increment metric before running api call,
|
||||
// as tailscaled will be unreachable after the call completes.
|
||||
incrementMetric("web_client_node_disconnect")
|
||||
return apiFetch("/local/v0/logout", "POST").catch(
|
||||
handlePostError("Failed to logout")
|
||||
)
|
||||
|
||||
/**
|
||||
* "new-auth-session" handles creating a new check mode session to
|
||||
* authorize the viewing user to manage the node via the web client.
|
||||
*/
|
||||
case "new-auth-session":
|
||||
return apiFetch<AuthSessionNewData>("/auth/session/new", "GET").catch(
|
||||
handlePostError("Failed to create new session")
|
||||
)
|
||||
|
||||
/**
|
||||
* "update-prefs" handles setting the node's tailscale prefs.
|
||||
*/
|
||||
case "update-prefs": {
|
||||
return optimisticMutate<NodeData>(
|
||||
"/data",
|
||||
apiFetch<LocalPrefsData>("/local/v0/prefs", "PATCH", t.data),
|
||||
(old) => ({
|
||||
...old,
|
||||
RunningSSHServer: t.data.RunSSHSet
|
||||
? Boolean(t.data.RunSSH)
|
||||
: old.RunningSSHServer,
|
||||
})
|
||||
)
|
||||
.then(
|
||||
() =>
|
||||
t.data.RunSSHSet &&
|
||||
incrementMetric(
|
||||
t.data.RunSSH
|
||||
? "web_client_ssh_enable"
|
||||
: "web_client_ssh_disable"
|
||||
)
|
||||
)
|
||||
.catch(handlePostError("Failed to update node preference"))
|
||||
}
|
||||
|
||||
/**
|
||||
* "update-routes" handles setting the node's advertised routes.
|
||||
*/
|
||||
case "update-routes": {
|
||||
const body: RoutesData = {
|
||||
SetRoutes: true,
|
||||
AdvertiseRoutes: t.data.map((r) => r.Route),
|
||||
}
|
||||
return optimisticMutate<NodeData>(
|
||||
"/data",
|
||||
apiFetch<void>("/routes", "POST", body),
|
||||
(old) => ({ ...old, AdvertisedRoutes: t.data })
|
||||
)
|
||||
.then(() => incrementMetric("web_client_advertise_routes_change"))
|
||||
.catch(handlePostError("Failed to update routes"))
|
||||
}
|
||||
|
||||
/**
|
||||
* "update-exit-node" handles updating the node's state as either
|
||||
* running as an exit node or using another node as an exit node.
|
||||
*/
|
||||
case "update-exit-node": {
|
||||
const id = t.data.ID
|
||||
const body: RoutesData = {
|
||||
SetExitNode: true,
|
||||
}
|
||||
if (id !== noExitNode.ID && id !== runAsExitNode.ID) {
|
||||
body.UseExitNode = id
|
||||
} else if (id === runAsExitNode.ID) {
|
||||
body.AdvertiseExitNode = true
|
||||
}
|
||||
const metrics: MetricName[] = []
|
||||
return optimisticMutate<NodeData>(
|
||||
"/data",
|
||||
apiFetch<void>("/routes", "POST", body),
|
||||
(old) => {
|
||||
// Only update metrics whose values have changed.
|
||||
if (old.AdvertisingExitNode !== Boolean(body.AdvertiseExitNode)) {
|
||||
metrics.push(
|
||||
body.AdvertiseExitNode
|
||||
? "web_client_advertise_exitnode_enable"
|
||||
: "web_client_advertise_exitnode_disable"
|
||||
)
|
||||
}
|
||||
if (Boolean(old.UsingExitNode) !== Boolean(body.UseExitNode)) {
|
||||
metrics.push(
|
||||
body.UseExitNode
|
||||
? "web_client_use_exitnode_enable"
|
||||
: "web_client_use_exitnode_disable"
|
||||
)
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined,
|
||||
AdvertisingExitNode: Boolean(body.AdvertiseExitNode),
|
||||
AdvertisingExitNodeApproved: Boolean(body.AdvertiseExitNode)
|
||||
? true // gets updated in revalidation
|
||||
: old.AdvertisingExitNodeApproved,
|
||||
}
|
||||
},
|
||||
false // skip final revalidation
|
||||
)
|
||||
.then(() => metrics.forEach((m) => incrementMetric(m)))
|
||||
.catch(handlePostError("Failed to update exit node"))
|
||||
}
|
||||
|
||||
default:
|
||||
assertNever(t)
|
||||
}
|
||||
},
|
||||
[handlePostError, mutate, optimisticMutate]
|
||||
)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
let csrfToken: string
|
||||
let synoToken: string | undefined // required for synology API requests
|
||||
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
|
||||
|
||||
/**
|
||||
* apiFetch wraps the standard JS fetch function with csrf header
|
||||
* management and param additions specific to the web client.
|
||||
*
|
||||
* apiFetch adds the `api` prefix to the request URL,
|
||||
* so endpoint should be provided without the `api` prefix
|
||||
* (i.e. provide `/data` rather than `api/data`).
|
||||
*/
|
||||
export function apiFetch<T>(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
body?: any
|
||||
): Promise<T> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams()
|
||||
if (synoToken) {
|
||||
nextParams.set("SynoToken", synoToken)
|
||||
} else {
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
}
|
||||
}
|
||||
const search = nextParams.toString()
|
||||
const url = `api${endpoint}${search ? `?${search}` : ""}`
|
||||
|
||||
var contentType: string
|
||||
if (unraidCsrfToken && method === "POST") {
|
||||
const params = new URLSearchParams()
|
||||
params.append("csrf_token", unraidCsrfToken)
|
||||
if (body) {
|
||||
params.append("ts_data", JSON.stringify(body))
|
||||
}
|
||||
body = params.toString()
|
||||
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
|
||||
} else {
|
||||
body = body ? JSON.stringify(body) : undefined
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": contentType,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: body,
|
||||
})
|
||||
.then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.headers.get("Content-Type") === "application/json") {
|
||||
return r.json()
|
||||
}
|
||||
})
|
||||
.then((r) => {
|
||||
r?.UnraidToken && setUnraidCsrfToken(r.UnraidToken)
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function updateCsrfToken(r: Response) {
|
||||
const tok = r.headers.get("X-CSRF-Token")
|
||||
if (tok) {
|
||||
csrfToken = tok
|
||||
}
|
||||
}
|
||||
|
||||
export function setSynoToken(token?: string) {
|
||||
synoToken = token
|
||||
}
|
||||
|
||||
function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
|
||||
/**
|
||||
* incrementMetric hits the client metrics local API endpoint to
|
||||
* increment the given counter metric by one.
|
||||
*/
|
||||
export function incrementMetric(metricName: MetricName) {
|
||||
const postData: MetricsPOSTData[] = [
|
||||
{
|
||||
Name: metricName,
|
||||
Type: "counter",
|
||||
Value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
apiFetch("/local/v0/upload-client-metrics", "POST", postData).catch(
|
||||
(error) => {
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type MetricsPOSTData = {
|
||||
Name: MetricName
|
||||
Type: MetricType
|
||||
Value: number
|
||||
}
|
||||
|
||||
type MetricType = "counter" | "gauge"
|
||||
|
||||
export type MetricName =
|
||||
| "web_client_advertise_exitnode_enable"
|
||||
| "web_client_advertise_exitnode_disable"
|
||||
| "web_client_use_exitnode_enable"
|
||||
| "web_client_use_exitnode_disable"
|
||||
| "web_client_ssh_enable"
|
||||
| "web_client_ssh_disable"
|
||||
| "web_client_node_connect"
|
||||
| "web_client_node_disconnect"
|
||||
| "web_client_advertise_routes_change"
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 522 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 704 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 236 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 203 B |
@@ -1,11 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_14876_118476)">
|
||||
<path d="M8.00065 14.6667C11.6825 14.6667 14.6673 11.6819 14.6673 8.00004C14.6673 4.31814 11.6825 1.33337 8.00065 1.33337C4.31875 1.33337 1.33398 4.31814 1.33398 8.00004C1.33398 11.6819 4.31875 14.6667 8.00065 14.6667Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 4V8L10.6667 9.33333" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_14876_118476">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 678 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 649 B |
@@ -1,11 +0,0 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_15367_14595)">
|
||||
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_15367_14595">
|
||||
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 738 B |
@@ -1,13 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_14860_117136)">
|
||||
<path d="M16.666 1.66667H3.33268C2.41221 1.66667 1.66602 2.41286 1.66602 3.33334V6.66667C1.66602 7.58715 2.41221 8.33334 3.33268 8.33334H16.666C17.5865 8.33334 18.3327 7.58715 18.3327 6.66667V3.33334C18.3327 2.41286 17.5865 1.66667 16.666 1.66667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.666 11.6667H3.33268C2.41221 11.6667 1.66602 12.4129 1.66602 13.3333V16.6667C1.66602 17.5871 2.41221 18.3333 3.33268 18.3333H16.666C17.5865 18.3333 18.3327 17.5871 18.3327 16.6667V13.3333C18.3327 12.4129 17.5865 11.6667 16.666 11.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 5H5.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 15H5.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_14860_117136">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4.16663V15.8333" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16602 10H15.8327" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 500 B |
@@ -1,18 +0,0 @@
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_13627_11860)">
|
||||
<path opacity="0.2" d="M3.8696 6.77137C5.56662 6.77137 6.94233 5.39567 6.94233 3.69865C6.94233 2.00163 5.56662 0.625919 3.8696 0.625919C2.17258 0.625919 0.796875 2.00163 0.796875 3.69865C0.796875 5.39567 2.17258 6.77137 3.8696 6.77137Z" fill="black"/>
|
||||
<path d="M3.8696 15.9327C5.56662 15.9327 6.94233 14.5569 6.94233 12.8599C6.94233 11.1629 5.56662 9.7872 3.8696 9.7872C2.17258 9.7872 0.796875 11.1629 0.796875 12.8599C0.796875 14.5569 2.17258 15.9327 3.8696 15.9327Z" fill="black"/>
|
||||
<path opacity="0.2" d="M3.8696 25.2646C5.56662 25.2646 6.94233 23.8889 6.94233 22.1919C6.94233 20.4949 5.56662 19.1192 3.8696 19.1192C2.17258 19.1192 0.796875 20.4949 0.796875 22.1919C0.796875 23.8889 2.17258 25.2646 3.8696 25.2646Z" fill="black"/>
|
||||
<path d="M13.0879 15.9327C14.7849 15.9327 16.1606 14.5569 16.1606 12.8599C16.1606 11.1629 14.7849 9.7872 13.0879 9.7872C11.3908 9.7872 10.0151 11.1629 10.0151 12.8599C10.0151 14.5569 11.3908 15.9327 13.0879 15.9327Z" fill="black"/>
|
||||
<path d="M13.0879 25.2646C14.7849 25.2646 16.1606 23.8889 16.1606 22.1919C16.1606 20.4949 14.7849 19.1192 13.0879 19.1192C11.3908 19.1192 10.0151 20.4949 10.0151 22.1919C10.0151 23.8889 11.3908 25.2646 13.0879 25.2646Z" fill="black"/>
|
||||
<path opacity="0.2" d="M13.0879 6.77137C14.7849 6.77137 16.1606 5.39567 16.1606 3.69865C16.1606 2.00163 14.7849 0.625919 13.0879 0.625919C11.3908 0.625919 10.0151 2.00163 10.0151 3.69865C10.0151 5.39567 11.3908 6.77137 13.0879 6.77137Z" fill="black"/>
|
||||
<path opacity="0.2" d="M22.1919 6.77137C23.8889 6.77137 25.2646 5.39567 25.2646 3.69865C25.2646 2.00163 23.8889 0.625919 22.1919 0.625919C20.4948 0.625919 19.1191 2.00163 19.1191 3.69865C19.1191 5.39567 20.4948 6.77137 22.1919 6.77137Z" fill="black"/>
|
||||
<path d="M22.1919 15.9327C23.8889 15.9327 25.2646 14.5569 25.2646 12.8599C25.2646 11.1629 23.8889 9.7872 22.1919 9.7872C20.4948 9.7872 19.1191 11.1629 19.1191 12.8599C19.1191 14.5569 20.4948 15.9327 22.1919 15.9327Z" fill="black"/>
|
||||
<path opacity="0.2" d="M22.1919 25.2646C23.8889 25.2646 25.2646 23.8889 25.2646 22.1919C25.2646 20.4949 23.8889 19.1192 22.1919 19.1192C20.4948 19.1192 19.1191 20.4949 19.1191 22.1919C19.1191 23.8889 20.4948 25.2646 22.1919 25.2646Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_13627_11860">
|
||||
<rect width="26" height="26" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg width="121" height="22" viewBox="0 0 121 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse cx="2.69191" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<ellipse cx="10.7676" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<ellipse opacity="0.2" cx="2.69191" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<circle opacity="0.2" cx="18.8433" cy="18.8434" r="2.69191" fill="#141414"/>
|
||||
<ellipse cx="10.7676" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<circle cx="18.8433" cy="10.7677" r="2.69191" fill="#141414"/>
|
||||
<ellipse opacity="0.2" cx="2.69191" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<ellipse opacity="0.2" cx="10.7676" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
|
||||
<circle opacity="0.2" cx="18.8433" cy="2.69191" r="2.69191" fill="#141414"/>
|
||||
<path d="M37.8847 19.9603C38.6525 19.9603 39.2764 19.8883 40.0202 19.7443V16.9609C39.5643 17.1289 39.0605 17.1769 38.5806 17.1769C37.4048 17.1769 36.9729 16.601 36.9729 15.4973V9.83453H40.0202V7.05116H36.9729V2.92409H33.6137V7.05116H31.4302V9.83453H33.6137V15.8092C33.6137 18.4486 35.0054 19.9603 37.8847 19.9603Z" fill="#141414"/>
|
||||
<path d="M45.5064 19.9603C47.306 19.9603 48.5057 19.3604 49.1056 18.4246C49.1536 18.8325 49.2975 19.3844 49.4895 19.7203H52.5128C52.3448 19.1444 52.2249 18.2326 52.2249 17.6328V11.0583C52.2249 8.34687 50.2813 6.81121 46.994 6.81121C44.4986 6.81121 42.555 7.747 41.4753 9.1147L43.3949 11.0103C44.2587 10.0505 45.3624 9.5466 46.7061 9.5466C48.3377 9.5466 49.0576 10.0985 49.0576 10.9143C49.0576 11.6101 48.5777 12.09 45.9863 12.09C43.4908 12.09 40.9714 13.1218 40.9714 16.0011C40.9714 18.6645 42.891 19.9603 45.5064 19.9603ZM46.1782 17.4168C44.8825 17.4168 44.2827 16.8649 44.2827 15.8812C44.2827 15.0174 45.0025 14.4415 46.2022 14.4415C48.1218 14.4415 48.6497 14.3215 49.0576 13.9136V14.9454C49.0576 16.3131 47.9058 17.4168 46.1782 17.4168Z" fill="#141414"/>
|
||||
<path d="M54.4086 5.44352H57.9118V2.30023H54.4086V5.44352ZM54.4805 19.7203H57.8398V7.05116H54.4805V19.7203Z" fill="#141414"/>
|
||||
<path d="M60.287 19.7203H63.6463V2.68414H60.287V19.7203Z" fill="#141414"/>
|
||||
<path d="M70.6285 19.9603C74.3237 19.9603 76.2193 18.0167 76.2193 15.9771C76.2193 14.1296 75.2835 12.7619 72.2122 12.21C70.0527 11.8261 68.709 11.3462 68.709 10.6024C68.709 9.95451 69.4768 9.49861 70.7725 9.49861C71.9242 9.49861 72.884 9.88252 73.6038 10.7223L75.7394 8.92274C74.6596 7.57904 72.884 6.81121 70.7725 6.81121C67.5332 6.81121 65.5177 8.53883 65.5177 10.6503C65.5177 12.9538 67.6292 13.9856 69.9087 14.3935C71.8043 14.7294 72.86 15.0893 72.86 15.9052C72.86 16.601 72.1162 17.1769 70.7005 17.1769C69.3088 17.1769 68.2291 16.529 67.7252 15.5692L64.8938 16.9129C65.5897 18.6405 67.9651 19.9603 70.6285 19.9603Z" fill="#141414"/>
|
||||
<path d="M83.7294 19.9603C86.1288 19.9603 87.8564 19.0005 89.1521 16.841L86.4648 15.4733C85.9609 16.481 85.1451 17.1769 83.7294 17.1769C81.5939 17.1769 80.4421 15.4493 80.4421 13.3617C80.4421 11.2742 81.6658 9.59459 83.7294 9.59459C85.0251 9.59459 85.8889 10.2904 86.3928 11.3462L89.1042 9.90652C88.1924 7.91497 86.3928 6.81121 83.7294 6.81121C79.3384 6.81121 77.0829 10.0265 77.0829 13.3617C77.0829 16.9849 79.8183 19.9603 83.7294 19.9603Z" fill="#141414"/>
|
||||
<path d="M94.5031 19.9603C96.3027 19.9603 97.5025 19.3604 98.1023 18.4246C98.1503 18.8325 98.2943 19.3844 98.4862 19.7203H101.51C101.342 19.1444 101.222 18.2326 101.222 17.6328V11.0583C101.222 8.34687 99.2781 6.81121 95.9908 6.81121C93.4954 6.81121 91.5518 7.747 90.472 9.1147L92.3916 11.0103C93.2554 10.0505 94.3592 9.5466 95.7029 9.5466C97.3345 9.5466 98.0543 10.0985 98.0543 10.9143C98.0543 11.6101 97.5744 12.09 94.983 12.09C92.4876 12.09 89.9682 13.1218 89.9682 16.0011C89.9682 18.6645 91.8877 19.9603 94.5031 19.9603ZM95.175 17.4168C93.8793 17.4168 93.2794 16.8649 93.2794 15.8812C93.2794 15.0174 93.9992 14.4415 95.199 14.4415C97.1185 14.4415 97.6464 14.3215 98.0543 13.9136V14.9454C98.0543 16.3131 96.9026 17.4168 95.175 17.4168Z" fill="#141414"/>
|
||||
<path d="M103.196 19.7203H106.555V2.68414H103.196V19.7203Z" fill="#141414"/>
|
||||
<path d="M114.617 19.9603C117.089 19.9603 119.08 18.9765 120.184 17.2249L117.641 15.5932C116.969 16.649 116.081 17.2249 114.617 17.2249C112.962 17.2249 111.762 16.3131 111.45 14.5375H121V13.3617C121 10.0265 118.96 6.81121 114.593 6.81121C110.442 6.81121 108.187 10.0505 108.187 13.3857C108.187 18.1367 111.762 19.9603 114.617 19.9603ZM111.57 11.8981C112.098 10.2904 113.202 9.5466 114.665 9.5466C116.321 9.5466 117.329 10.5304 117.665 11.8981H111.57Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |