Compare commits
1 Commits
awly/versi
...
vm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63619299b |
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
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
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
|
||||
28
.github/workflows/checklocks.yml
vendored
28
.github/workflows/checklocks.yml
vendored
@@ -1,28 +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@v4
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- name: Run checklocks vet
|
||||
# TODO: remove || true once we have applied checklocks annotations everywhere.
|
||||
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
|
||||
31
.github/workflows/cifuzz.yml
vendored
Normal file
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
|
||||
10
.github/workflows/codeql-analysis.yml
vendored
10
.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,11 +43,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
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.
|
||||
@@ -60,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@v2
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -74,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
54
.github/workflows/cross-android.yml
vendored
Normal file
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
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
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
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
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
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
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
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@v4
|
||||
- name: "Build Docker image"
|
||||
run: docker build .
|
||||
27
.github/workflows/flakehub-publish-tagged.yml
vendored
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@v4"
|
||||
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 }}"
|
||||
14
.github/workflows/go-licenses.yml
vendored
14
.github/workflows/go-licenses.yml
vendored
@@ -17,15 +17,15 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-licenses:
|
||||
tailscale:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
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@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
@@ -50,11 +50,11 @@ jobs:
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
uses: peter-evans/create-pull-request@18f90432bedd2afd6a825469ffd38aa24712a91d #v4.1.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: License Updater <noreply+license-updater@tailscale.com>
|
||||
committer: License Updater <noreply+license-updater@tailscale.com>
|
||||
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"
|
||||
|
||||
42
.github/workflows/go_generate.yml
vendored
Normal file
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
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)
|
||||
40
.github/workflows/golangci-lint.yml
vendored
40
.github/workflows/golangci-lint.yml
vendored
@@ -1,40 +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@v4
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# Note: this is the 'v3' tag as of 2023-08-14
|
||||
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
|
||||
with:
|
||||
version: v1.54.2
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
38
.github/workflows/govulncheck.yml
vendored
38
.github/workflows/govulncheck.yml
vendored
@@ -1,38 +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@v4
|
||||
|
||||
- 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 ./...
|
||||
|
||||
- uses: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
payload: >
|
||||
{
|
||||
"attachments": [{
|
||||
"title": "${{ job.status }}: ${{ github.workflow }}",
|
||||
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
|
||||
"text": "${{ github.repository }}@${{ github.sha }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
102
.github/workflows/installer.yml
vendored
102
.github/workflows/installer.yml
vendored
@@ -1,102 +0,0 @@
|
||||
name: test installer.sh
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
|
||||
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:18.04"
|
||||
- "ubuntu:20.04"
|
||||
- "ubuntu:22.04"
|
||||
- "ubuntu:22.10"
|
||||
- "ubuntu:23.04"
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
- "parrotsec/core:lts-amd64"
|
||||
- "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.14"
|
||||
- "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" }
|
||||
- { image: "ubuntu:23.04", deps: "wget" }
|
||||
# Ubuntu 16.04 also needs apt-transport-https installed.
|
||||
- { image: "ubuntu:16.04", deps: "curl apt-transport-https" }
|
||||
- { image: "ubuntu:16.04", deps: "wget apt-transport-https" }
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
options: --user root
|
||||
steps:
|
||||
- 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@v4
|
||||
- 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
|
||||
24
.github/workflows/kubemanifests.yaml
vendored
24
.github/workflows/kubemanifests.yaml
vendored
@@ -1,24 +0,0 @@
|
||||
name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- './cmd/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@v4
|
||||
- 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"
|
||||
44
.github/workflows/license.yml
vendored
Normal file
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
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
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
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'
|
||||
|
||||
112
.github/workflows/static-analysis.yml
vendored
Normal file
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'
|
||||
544
.github/workflows/test.yml
vendored
544
.github/workflows/test.yml
vendored
@@ -1,544 +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@v4
|
||||
- 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@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.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: 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
|
||||
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.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 "^$"
|
||||
|
||||
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@v4
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.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"
|
||||
|
||||
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@v4
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
|
||||
android:
|
||||
# similar to cross above, but android fails to build a few pieces of the
|
||||
# repo. We should fix those pieces, they're small, but as a stepping stone,
|
||||
# only test the subset of android that our past smoke test checked.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
|
||||
- name: build some
|
||||
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: arm64
|
||||
|
||||
wasm: # builds tsconnect, which is the only wasm build we support
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.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
|
||||
|
||||
tailscale_go: # Subset of tests that depend on our custom Go toolchain.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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: upload crash
|
||||
uses: actions/upload-artifact@v3
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ./out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check depaware
|
||||
run: |
|
||||
export PATH=$(./tool/go env GOROOT)/bin:$PATH
|
||||
find . -name 'depaware.txt' | xargs -n1 dirname | xargs ./tool/go run github.com/tailscale/depaware --check
|
||||
|
||||
go_generate:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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@v4
|
||||
- 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@v4
|
||||
- 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: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"title": "Failure: ${{ github.workflow }}",
|
||||
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
|
||||
"text": "${{ github.repository }}@${{ github.ref_name }}: <https://github.com/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>",
|
||||
"fields": [{ "value": ${{ toJson(github.event.head_commit.message) }}, "short": false }],
|
||||
"footer": "${{ github.event.head_commit.committer.name }} at ${{ github.event.head_commit.timestamp }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
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@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
30
.github/workflows/tsconnect-pkg-publish.yml
vendored
Normal file
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
|
||||
49
.github/workflows/update-flake.yml
vendored
49
.github/workflows/update-flake.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: update-flake
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod. Also
|
||||
# allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/workflows/update-flakes.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-flake:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.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@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
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
|
||||
50
.github/workflows/vm.yml
vendored
Normal file
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: ubuntu-22.04
|
||||
|
||||
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: "$HOME/.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'
|
||||
66
.github/workflows/windows.yml
vendored
Normal file
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'
|
||||
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -22,23 +22,3 @@ cmd/tailscaled/tailscaled
|
||||
# direnv config, this may be different for other people so it's probably safer
|
||||
# to make this nonspecific.
|
||||
.envrc
|
||||
|
||||
# Ignore personal VS Code settings
|
||||
.vscode/
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,61 +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
|
||||
- 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:
|
||||
# Enable all rules by default; we don't use invisible unicode runes.
|
||||
bidichk:
|
||||
|
||||
gofmt:
|
||||
rewrite-rules:
|
||||
- pattern: 'interface{}'
|
||||
replacement: 'any'
|
||||
|
||||
goimports:
|
||||
|
||||
misspell:
|
||||
|
||||
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 +0,0 @@
|
||||
/tailcfg/ @tailscale/control-protocol-owners
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,5 +1,6 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
############################################################################
|
||||
#
|
||||
@@ -31,7 +32,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.21-alpine AS build-env
|
||||
FROM golang:1.19-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
@@ -47,7 +48,8 @@ RUN go install \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
nhooyr.io/websocket \
|
||||
github.com/mdlayher/netlink
|
||||
github.com/mdlayher/netlink \
|
||||
golang.zx2c4.com/wireguard/device
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -61,15 +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.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 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,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.
|
||||
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables iputils
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2020 Tailscale Inc & AUTHORS.
|
||||
Copyright (c) 2020 Tailscale & AUTHORS.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
75
Makefile
75
Makefile
@@ -1,87 +1,56 @@
|
||||
IMAGE_REPO ?= tailscale/tailscale
|
||||
SYNO_ARCH ?= "amd64"
|
||||
SYNO_DSM ?= "7"
|
||||
TAGS ?= "latest"
|
||||
|
||||
vet: ## Run go vet
|
||||
usage:
|
||||
echo "See Makefile"
|
||||
|
||||
vet:
|
||||
./tool/go vet ./...
|
||||
|
||||
tidy: ## Run go mod tidy
|
||||
tidy:
|
||||
./tool/go mod tidy
|
||||
|
||||
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 \
|
||||
updatedeps:
|
||||
./tool/go run github.com/tailscale/depaware --update \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
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 \
|
||||
depaware:
|
||||
./tool/go run github.com/tailscale/depaware --check \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
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)
|
||||
|
||||
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}
|
||||
spk:
|
||||
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o tailscale.spk --source=. --goarch=${SYNO_ARCH} --dsm-version=${SYNO_DSM}
|
||||
|
||||
spkall: ## Build synology packages for all architectures and DSM versions
|
||||
./tool/go run ./cmd/dist build synology
|
||||
spkall:
|
||||
mkdir -p spks
|
||||
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o spks --source=. --goarch=all --dsm-version=all
|
||||
|
||||
pushspk: spk ## 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} 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} PUSH=true TARGET=operator ./build_docker.sh
|
||||
|
||||
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
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.21. (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://github.com/golang/go/wiki/CommitMessage).
|
||||
|
||||
## About Us
|
||||
|
||||
[Tailscale](https://tailscale.com/) is primarily developed by the
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.53.0
|
||||
1.31.0
|
||||
|
||||
@@ -1,174 +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 (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// 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 a new route advertisement if the route is not already
|
||||
// being advertised.
|
||||
AdvertiseRoute(netip.Prefix) error
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// mu guards the fields that follow
|
||||
mu sync.Mutex
|
||||
// domains is a map of lower case domain names with no trailing dot, to a
|
||||
// list of resolved IP addresses.
|
||||
domains map[string][]netip.Addr
|
||||
}
|
||||
|
||||
// NewAppConnector creates a new AppConnector.
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
|
||||
return &AppConnector{
|
||||
logf: logger.WithPrefix(logf, "appc: "),
|
||||
routeAdvertiser: routeAdvertiser,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDomains 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.
|
||||
func (e *AppConnector) UpdateDomains(domains []string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var old map[string][]netip.Addr
|
||||
old, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(d)
|
||||
e.domains[d] = old[d]
|
||||
}
|
||||
e.logf("handling domains: %v", xmaps.Keys(e.domains))
|
||||
}
|
||||
|
||||
// Domains returns the currently configured domain list.
|
||||
func (e *AppConnector) Domains() views.Slice[string] {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
return views.SliceOf(xmaps.Keys(e.domains))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
h, err := p.AnswerHeader()
|
||||
if err == dnsmessage.ErrSectionDone {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if h.Class != dnsmessage.ClassINET {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
domain := h.Name.String()
|
||||
if len(domain) == 0 {
|
||||
return
|
||||
}
|
||||
if domain[len(domain)-1] == '.' {
|
||||
domain = domain[:len(domain)-1]
|
||||
}
|
||||
domain = strings.ToLower(domain)
|
||||
e.logf("[v2] observed DNS response for %s", domain)
|
||||
|
||||
e.mu.Lock()
|
||||
addrs, ok := e.domains[domain]
|
||||
e.mu.Unlock()
|
||||
if !ok {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var addr netip.Addr
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr = netip.AddrFrom4(r.A)
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr = netip.AddrFrom16(r.AAAA)
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slices.Contains(addrs, addr) {
|
||||
continue
|
||||
}
|
||||
// TODO(raggi): check for existing prefixes
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
|
||||
e.logf("failed to advertise route for %v: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
|
||||
e.mu.Lock()
|
||||
e.domains[domain] = append(addrs, addr)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestUpdateDomains(t *testing.T) {
|
||||
a := NewAppConnector(t.Logf, nil)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
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"})
|
||||
|
||||
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"})
|
||||
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
rc := &routeCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
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"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
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"))
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
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
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if !slices.Equal(rc.routes, wantRoutes) {
|
||||
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// routeCollector is a test helper that collects the list of routes advertised
|
||||
type routeCollector struct {
|
||||
routes []netip.Prefix
|
||||
}
|
||||
|
||||
// routeCollector implements RouteAdvertiser
|
||||
var _ RouteAdvertiser = (*routeCollector)(nil)
|
||||
|
||||
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx)
|
||||
return nil
|
||||
}
|
||||
@@ -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,20 +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. 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
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" "$@"
|
||||
|
||||
@@ -23,52 +23,26 @@ set -eu
|
||||
export PATH=$PWD/tool:$PATH
|
||||
|
||||
eval $(./build_dist.sh shellvars)
|
||||
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.16"
|
||||
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}}"
|
||||
|
||||
case "$TARGET" in
|
||||
client)
|
||||
DEFAULT_REPOS="tailscale/tailscale"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled, \
|
||||
tailscale.com/cmd/containerboot:/usr/local/bin/containerboot" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
operator)
|
||||
DEFAULT_REPOS="tailscale/k8s-operator"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-operator:/usr/local/bin/operator" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -103,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.
|
||||
@@ -150,9 +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 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.
|
||||
@@ -437,7 +438,7 @@ 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
|
||||
@@ -458,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,23 +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"
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
|
||||
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
|
||||
// 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
|
||||
@@ -25,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
|
||||
}
|
||||
@@ -34,18 +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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
@@ -43,18 +46,17 @@ type Device struct {
|
||||
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
|
||||
@@ -212,20 +214,8 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +65,7 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var keys struct {
|
||||
Keys []*Key `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := make([]string, 0, len(keys.Keys))
|
||||
for _, k := range keys.Keys {
|
||||
ret = append(ret, k.ID)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// CreateKey creates a new key for the current user. Currently, only auth keys
|
||||
// can be created. 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 := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key struct {
|
||||
Key
|
||||
Secret string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &key); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return key.Secret, &key.Key, nil
|
||||
}
|
||||
|
||||
// Key returns the metadata for the given key ID. Currently, capabilities are
|
||||
// only returned for auth keys, API keys only return general metadata.
|
||||
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var key Key
|
||||
if err := json.Unmarshal(b, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DeleteKey deletes the key with the given ID.
|
||||
func (c *Client) DeleteKey(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
if sc != nil {
|
||||
t.Errorf("want nil for null")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("reading null: %v", err)
|
||||
}
|
||||
|
||||
sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`))
|
||||
if err != nil {
|
||||
t.Errorf("reading object: %v", err)
|
||||
} else if sc == nil {
|
||||
t.Errorf("want non-nil for object")
|
||||
} else if sc.TCP == nil {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +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.21
|
||||
//go:build !go1.19
|
||||
// +build !go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_21_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
|
||||
|
||||
|
||||
@@ -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,8 +12,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
|
||||
@@ -23,7 +23,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// 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 Go clients for the Tailscale LocalAPI and
|
||||
// Package tailscale contains Go clients for the Tailscale Local API and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// Warning: this package is in development and makes no API compatibility
|
||||
@@ -113,7 +115,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
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) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
prebuilt "github.com/tailscale/web-client-prebuilt"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
return http.FileServer(http.FS(prebuilt.FS())), nil
|
||||
}
|
||||
|
||||
// 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... (might take ~30s)", 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,248 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"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, error) {
|
||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case whoIsErr != nil:
|
||||
return nil, nil, errNotUsingTailscale
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, errNotOwner
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, 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, errNoSession
|
||||
} else if session.isExpired(s.timeNow()) {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
return session, whoIs, 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) {
|
||||
d, err := s.getOrAwaitAuth(ctx, "", src.Node.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: src.Node.ID,
|
||||
SrcUser: src.UserProfile.ID,
|
||||
AuthID: d.ID,
|
||||
AuthURL: d.URL,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
s.browserSessions.Store(sid, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
d, err := s.getOrAwaitAuth(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 d.Complete {
|
||||
session.Authenticated = d.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrAwaitAuth connects to the control server for user auth,
|
||||
// with the following behavior:
|
||||
//
|
||||
// 1. If authID is provided empty, a new auth URL is created on the control
|
||||
// server and reported back here, which can then be used to redirect the
|
||||
// user on the frontend.
|
||||
// 2. If authID is provided non-empty, the connection to control blocks until
|
||||
// the user has completed authenticating the associated auth URL,
|
||||
// or until ctx is canceled.
|
||||
func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
|
||||
type data struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed request: %s", body)
|
||||
}
|
||||
var authResp *tailcfg.WebClientAuthResponse
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for i := 0; i < 5; i++ {
|
||||
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")
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
<script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-8612dca6.css">
|
||||
</head>
|
||||
<body>
|
||||
<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,26 +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" />
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
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,44 +0,0 @@
|
||||
{
|
||||
"name": "webclient",
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.16.1",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-plugin-svgr": "^3.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
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) (ar authResponse, err error) {
|
||||
_, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
return ar, err
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
return ar, errors.New("user is not an admin")
|
||||
}
|
||||
|
||||
return authResponse{OK: 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,76 +0,0 @@
|
||||
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(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST",
|
||||
body?: any,
|
||||
params?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams(params)
|
||||
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,
|
||||
}).then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import LegacyClientView from "src/components/views/legacy-client-view"
|
||||
import LoginClientView from "src/components/views/login-client-view"
|
||||
import ReadonlyClientView from "src/components/views/readonly-client-view"
|
||||
import useAuth, { AuthResponse, SessionsCallbacks } from "src/hooks/auth"
|
||||
import useNodeData from "src/hooks/node-data"
|
||||
import ManagementClientView from "./views/management-client-view"
|
||||
|
||||
export default function App() {
|
||||
const { data: auth, loading: loadingAuth, sessions } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14">
|
||||
{loadingAuth ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : (
|
||||
<WebClient auth={auth} sessions={sessions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebClient({
|
||||
auth,
|
||||
sessions,
|
||||
}: {
|
||||
auth?: AuthResponse
|
||||
sessions: SessionsCallbacks
|
||||
}) {
|
||||
const { data, refreshData, updateNode } = useNodeData()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginClientView
|
||||
data={data}
|
||||
onLoginClick={() => updateNode({ Reauthenticate: true })}
|
||||
/>
|
||||
) : data.DebugMode === "full" && auth?.ok ? (
|
||||
// Render new client interface in management mode.
|
||||
<ManagementClientView {...data} />
|
||||
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
|
||||
// Render new client interface in readonly mode.
|
||||
<ReadonlyClientView data={data} auth={auth} sessions={sessions} />
|
||||
) : (
|
||||
// Render legacy client interface.
|
||||
<LegacyClientView
|
||||
data={data}
|
||||
refreshData={refreshData}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
)}
|
||||
{data && <Footer licensesURL={data.LicensesURL} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer(props: { licensesURL: string; className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cx("container max-w-lg mx-auto text-center", props.className)}
|
||||
>
|
||||
<a
|
||||
className="text-xs text-gray-500 hover:text-gray-600"
|
||||
href={props.licensesURL}
|
||||
>
|
||||
Open Source Licenses
|
||||
</a>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
|
||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||
// purely to ease migration to the new React-based web client, and will
|
||||
// eventually be completely removed.
|
||||
|
||||
export default function LegacyClientView({
|
||||
data,
|
||||
refreshData,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
|
||||
<IP data={data} />
|
||||
{data.Status === "NeedsMachineAuth" ? (
|
||||
<div className="mb-4">
|
||||
This device is authorized, but needs approval from a network admin
|
||||
before it can connect to the network.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p>
|
||||
You are connected! Access this device over Tailscale using the
|
||||
device name or IP address above.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={cx("button button-medium mb-4", {
|
||||
"button-red": data.AdvertiseExitNode,
|
||||
"button-blue": !data.AdvertiseExitNode,
|
||||
})}
|
||||
id="enabled"
|
||||
onClick={() =>
|
||||
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
|
||||
}
|
||||
>
|
||||
{data.AdvertiseExitNode
|
||||
? "Stop advertising Exit Node"
|
||||
: "Advertise as Exit Node"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header({
|
||||
data,
|
||||
refreshData,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
return (
|
||||
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 23 23"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="flex-shrink-0 mr-4"
|
||||
>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="11.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="flex items-center justify-end space-x-2 w-2/3">
|
||||
{data.Profile &&
|
||||
data.Status !== "NoState" &&
|
||||
data.Status !== "NeedsLogin" && (
|
||||
<>
|
||||
<div className="text-right w-full leading-4">
|
||||
<h4 className="truncate leading-normal">
|
||||
{data.Profile.LoginName}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500 text-right">
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Switch account
|
||||
</button>{" "}
|
||||
|{" "}
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>{" "}
|
||||
|{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
apiFetch("/local/v0/logout", "POST")
|
||||
.then(refreshData)
|
||||
.catch((err) => alert("Logout failed: " + err.message))
|
||||
}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{data.Profile.ProfilePicURL ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function IP(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
if (!data.IP) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
|
||||
<div className="flex items-center min-width-0">
|
||||
<svg
|
||||
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 className="font-semibold truncate mr-2">
|
||||
{data.DeviceName || "Your device"}
|
||||
</h4>
|
||||
</div>
|
||||
<h5>{data.IP}</h5>
|
||||
</div>
|
||||
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
|
||||
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
|
||||
{data.IsSynology && (
|
||||
<>
|
||||
, DSM{data.DSMVersion}
|
||||
{data.TUNMode || (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<a
|
||||
href="https://tailscale.com/kb/1152/synology-outbound/"
|
||||
className="link-underline text-gray-600"
|
||||
target="_blank"
|
||||
aria-label="Configure outbound synology traffic"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
outgoing access not configured
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
|
||||
/**
|
||||
* LoginClientView is rendered when the client is not authenticated
|
||||
* to a tailnet.
|
||||
*/
|
||||
export default function LoginClientView({
|
||||
data,
|
||||
onLoginClick,
|
||||
}: {
|
||||
data: NodeData
|
||||
onLoginClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<TailscaleIcon className="my-2 mb-8" />
|
||||
{data.IP ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device's key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
|
||||
<p className="text-gray-700">
|
||||
Get started by logging in to your Tailscale network.
|
||||
Or, learn more at{" "}
|
||||
<a href="https://tailscale.com/" className="link" target="_blank">
|
||||
tailscale.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
export default function ManagementClientView(props: NodeData) {
|
||||
return (
|
||||
<div className="px-5 mb-12">
|
||||
<div className="flex justify-between mb-12">
|
||||
<TailscaleIcon />
|
||||
<div className="flex">
|
||||
<p className="mr-2">{props.Profile.LoginName}</p>
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
|
||||
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<p className="font-medium ml-3">{props.DeviceName}</p>
|
||||
</div>
|
||||
<p className="tracking-widest">{props.IP}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 pt-2">
|
||||
Tailscale is up and running. You can connect to this device from devices
|
||||
in your tailnet by using its name or IP address.
|
||||
</p>
|
||||
<button className="button button-blue mt-6">Advertise exit node</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from "react"
|
||||
import { AuthResponse, AuthType, SessionsCallbacks } from "src/hooks/auth"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
/**
|
||||
* ReadonlyClientView is rendered when the web interface is either
|
||||
*
|
||||
* 1. being viewed by a user not allowed to manage the node
|
||||
* (e.g. user does not own the node)
|
||||
*
|
||||
* 2. or the user is allowed to manage the node but does not
|
||||
* yet have a valid browser session.
|
||||
*/
|
||||
export default function ReadonlyClientView({
|
||||
data,
|
||||
auth,
|
||||
sessions,
|
||||
}: {
|
||||
data: NodeData
|
||||
auth?: AuthResponse
|
||||
sessions: SessionsCallbacks
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-52 mx-auto">
|
||||
<TailscaleLogo />
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={data.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Managed by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{data.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
|
||||
<div className="flex gap-3">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{data.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{data.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
{auth?.authNeeded == AuthType.tailscale && (
|
||||
<button
|
||||
className="button button-blue ml-6"
|
||||
onClick={() => {
|
||||
sessions
|
||||
.new()
|
||||
.then((url) => window.open(url, "_blank"))
|
||||
.then(() => sessions.wait())
|
||||
}}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
|
||||
export enum AuthType {
|
||||
synology = "synology",
|
||||
tailscale = "tailscale",
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
ok: boolean
|
||||
authNeeded?: AuthType
|
||||
}
|
||||
|
||||
export type SessionsCallbacks = {
|
||||
new: () => Promise<string> // creates new auth session and returns authURL
|
||||
wait: () => Promise<void> // blocks until auth is completed
|
||||
}
|
||||
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
// for the web client.
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
|
||||
const loadAuth = useCallback(() => {
|
||||
setLoading(true)
|
||||
return apiFetch("/auth", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
switch ((d as AuthResponse).authNeeded) {
|
||||
case AuthType.synology:
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setLoading(false)
|
||||
})
|
||||
break
|
||||
default:
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false)
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch("/auth/session/new", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.authUrl)
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const waitForSessionCompletion = useCallback(() => {
|
||||
return apiFetch("/auth/session/wait", "GET")
|
||||
.then(() => loadAuth()) // refresh auth data
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
sessions: {
|
||||
new: newSession,
|
||||
wait: waitForSessionCompletion,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: string
|
||||
DeviceName: string
|
||||
IP: string
|
||||
AdvertiseExitNode: boolean
|
||||
AdvertiseRoutes: string
|
||||
LicensesURL: string
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
|
||||
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type NodeUpdate = {
|
||||
AdvertiseRoutes?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
Reauthenticate?: boolean
|
||||
ForceLogout?: boolean
|
||||
}
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||
|
||||
const refreshData = useCallback(
|
||||
() =>
|
||||
apiFetch("/data", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d: NodeData) => {
|
||||
setData(d)
|
||||
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
|
||||
})
|
||||
.catch((error) => console.error(error)),
|
||||
[setData]
|
||||
)
|
||||
|
||||
const updateNode = useCallback(
|
||||
(update: NodeUpdate) => {
|
||||
// The contents of this function are mostly copied over
|
||||
// from the legacy client's web.html file.
|
||||
// It makes all data updates through one API endpoint.
|
||||
// As we build out the web client in React,
|
||||
// this endpoint will eventually be deprecated.
|
||||
|
||||
if (isPosting || !data) {
|
||||
return
|
||||
}
|
||||
setIsPosting(true)
|
||||
|
||||
update = {
|
||||
...update,
|
||||
// Default to current data value for any unset fields.
|
||||
AdvertiseRoutes:
|
||||
update.AdvertiseRoutes !== undefined
|
||||
? update.AdvertiseRoutes
|
||||
: data.AdvertiseRoutes,
|
||||
AdvertiseExitNode:
|
||||
update.AdvertiseExitNode !== undefined
|
||||
? update.AdvertiseExitNode
|
||||
: data.AdvertiseExitNode,
|
||||
}
|
||||
|
||||
apiFetch("/data", "POST", update, { up: "true" })
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
setIsPosting(false)
|
||||
const err = r["error"]
|
||||
if (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
const url = r["url"]
|
||||
if (url) {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
.catch((err) => alert("Failed operation: " + err.message))
|
||||
},
|
||||
[data]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Initial data load.
|
||||
refreshData()
|
||||
|
||||
// Refresh on browser tab focus.
|
||||
const onVisibilityChange = () => {
|
||||
document.visibilityState === "visible" && refreshData()
|
||||
}
|
||||
window.addEventListener("visibilitychange", onVisibilityChange)
|
||||
return () => {
|
||||
// Cleanup browser tab listener.
|
||||
window.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
},
|
||||
// Run once.
|
||||
[]
|
||||
)
|
||||
|
||||
return { data, refreshData, updateNode, isPosting }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="20" fill="#F7F5F4"/>
|
||||
<g clip-path="url(#clip0_13627_11903)">
|
||||
<path d="M26.6666 11.6667H13.3333C12.4128 11.6667 11.6666 12.4129 11.6666 13.3333V16.6667C11.6666 17.5871 12.4128 18.3333 13.3333 18.3333H26.6666C27.5871 18.3333 28.3333 17.5871 28.3333 16.6667V13.3333C28.3333 12.4129 27.5871 11.6667 26.6666 11.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26.6666 21.6667H13.3333C12.4128 21.6667 11.6666 22.4129 11.6666 23.3333V26.6667C11.6666 27.5871 12.4128 28.3333 13.3333 28.3333H26.6666C27.5871 28.3333 28.3333 27.5871 28.3333 26.6667V23.3333C28.3333 22.4129 27.5871 21.6667 26.6666 21.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 15H15.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 25H15.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<circle cx="34" cy="34" r="4.5" fill="#1EA672" stroke="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_13627_11903">
|
||||
<rect width="20" height="20" fill="white" transform="translate(10 10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -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 |
@@ -1,130 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
.bg-gray-0 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
html {
|
||||
letter-spacing: -0.015em;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.link {
|
||||
--text-opacity: 1;
|
||||
color: #4b70cc;
|
||||
color: rgba(75, 112, 204, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
--text-opacity: 1;
|
||||
color: #19224a;
|
||||
color: rgba(25, 34, 74, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-underline:hover,
|
||||
.link-underline:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-muted {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.link-muted:hover,
|
||||
.link-muted:active {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.button {
|
||||
font-weight: 500;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button-blue {
|
||||
--bg-opacity: 1;
|
||||
background-color: #4b70cc;
|
||||
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #4b70cc;
|
||||
border-color: rgba(75, 112, 204, var(--border-opacity));
|
||||
--text-opacity: 1;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, var(--text-opacity));
|
||||
}
|
||||
|
||||
.button-blue:enabled:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #3f5db3;
|
||||
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #3f5db3;
|
||||
border-color: rgba(63, 93, 179, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-blue:disabled {
|
||||
--text-opacity: 1;
|
||||
color: #cedefd;
|
||||
color: rgba(206, 222, 253, var(--text-opacity));
|
||||
--bg-opacity: 1;
|
||||
background-color: #6c94ec;
|
||||
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #6c94ec;
|
||||
border-color: rgba(108, 148, 236, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-red {
|
||||
background-color: #d04841;
|
||||
border-color: #d04841;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-red:enabled:hover {
|
||||
background-color: #b22d30;
|
||||
border-color: #b22d30;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "src/components/app"
|
||||
|
||||
declare var window: any
|
||||
// This is used to determine if the react client is built.
|
||||
window.Tailscale = true
|
||||
|
||||
const rootEl = document.createElement("div")
|
||||
rootEl.id = "app-root"
|
||||
rootEl.classList.add("relative", "z-0")
|
||||
document.body.append(rootEl)
|
||||
|
||||
const root = createRoot(rootEl)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export default function ProfilePic({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{url ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// synology.go contains handlers and logic, such as authentication,
|
||||
// that is specific to running the web client on Synology.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/groupmember"
|
||||
)
|
||||
|
||||
// authorizeSynology authenticates the logged-in Synology user and verifies
|
||||
// that they are authorized to use the web client.
|
||||
// The returned authResponse indicates if the user is authorized,
|
||||
// and if additional steps are needed to authenticate the user.
|
||||
// If the user is authenticated, but not authorized to use the client, an error is returned.
|
||||
func authorizeSynology(r *http.Request) (resp authResponse, err error) {
|
||||
if !hasSynoToken(r) {
|
||||
return authResponse{OK: false, AuthNeeded: synoAuth}, nil
|
||||
}
|
||||
|
||||
// authenticate the Synology user
|
||||
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("auth: %v: %s", err, out)
|
||||
}
|
||||
user := strings.TrimSpace(string(out))
|
||||
|
||||
// check if the user is in the administrators group
|
||||
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
if !isAdmin {
|
||||
return resp, errors.New("not a member of administrators group")
|
||||
}
|
||||
|
||||
return authResponse{OK: true}, nil
|
||||
}
|
||||
|
||||
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
|
||||
func hasSynoToken(r *http.Request) bool {
|
||||
if r.Header.Get("X-Syno-Token") != "" {
|
||||
return true
|
||||
}
|
||||
if r.URL.Query().Get("SynoToken") != "" {
|
||||
return true
|
||||
}
|
||||
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ES2017",
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"types": ["vite-plugin-svgr/client", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/// <reference types="vitest" />
|
||||
import { createLogger, defineConfig } from "vite"
|
||||
import rewrite from "vite-plugin-rewrite-all"
|
||||
import svgr from "vite-plugin-svgr"
|
||||
import paths from "vite-tsconfig-paths"
|
||||
|
||||
// Use a custom logger that filters out Vite's logging of server URLs, since
|
||||
// they are an attractive nuisance (we run a proxy in front of Vite, and the
|
||||
// tailscale web client should be accessed through that).
|
||||
// Unfortunately there's no option to disable this logging, so the best we can
|
||||
// do it to ignore calls from a specific function.
|
||||
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
|
||||
const originalInfoLog = filteringLogger.info
|
||||
filteringLogger.info = (...args) => {
|
||||
if (new Error("ignored").stack?.includes("printServerUrls")) {
|
||||
return
|
||||
}
|
||||
originalInfoLog.apply(filteringLogger, args)
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [
|
||||
paths(),
|
||||
svgr(),
|
||||
// By default, the Vite dev server doesn't handle dots
|
||||
// in path names and treats them as static files.
|
||||
// This plugin changes Vite's routing logic to fix this.
|
||||
// See: https://github.com/vitejs/vite/issues/2415
|
||||
rewrite(),
|
||||
],
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: false,
|
||||
},
|
||||
esbuild: {
|
||||
logOverride: {
|
||||
// Silence a warning about `this` being undefined in ESM when at the
|
||||
// top-level. The way JSX is transpiled causes this to happen, but it
|
||||
// isn't a problem.
|
||||
// See: https://github.com/vitejs/vite/issues/8644
|
||||
"this-is-undefined-in-esm": "silent",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// This needs to be 127.0.0.1 instead of localhost, because of how our
|
||||
// Go proxy connects to it.
|
||||
host: "127.0.0.1",
|
||||
// If you change the port, be sure to update the proxy in adminhttp.go too.
|
||||
port: 4000,
|
||||
// Don't proxy the WebSocket connection used for live reloading by running
|
||||
// it on a separate port.
|
||||
hmr: {
|
||||
protocol: "ws",
|
||||
port: 4001,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
testTimeout: 20000,
|
||||
environment: "jsdom",
|
||||
deps: {
|
||||
inline: ["date-fns", /\.wasm\?url$/],
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
customLogger: filteringLogger,
|
||||
})
|
||||
@@ -1,821 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package web provides the Tailscale client for web.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/licenses"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// ListenPort is the static port used for the web client when run inside tailscaled.
|
||||
// (5252 are the numbers above the letters "TSTS" on a qwerty keyboard.)
|
||||
const ListenPort = 5252
|
||||
|
||||
// Server is the backend server for a Tailscale web client.
|
||||
type Server struct {
|
||||
mode ServerMode
|
||||
|
||||
logf logger.Logf
|
||||
lc *tailscale.LocalClient
|
||||
timeNow func() time.Time
|
||||
|
||||
// devMode indicates that the server run with frontend assets
|
||||
// served by a Vite dev server, allowing for local development
|
||||
// on the web client frontend.
|
||||
devMode bool
|
||||
cgiMode bool
|
||||
pathPrefix string
|
||||
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
assetsCleanup func() // called from Server.Shutdown
|
||||
|
||||
// browserSessions is an in-memory cache of browser sessions for the
|
||||
// full management web client, which is only accessible over Tailscale.
|
||||
//
|
||||
// Users obtain a valid browser session by connecting to the web client
|
||||
// over Tailscale and verifying their identity by authenticating on the
|
||||
// control server.
|
||||
//
|
||||
// browserSessions get reset on every Server restart.
|
||||
//
|
||||
// The map provides a lookup of the session by cookie value
|
||||
// (browserSession.ID => browserSession).
|
||||
browserSessions sync.Map
|
||||
}
|
||||
|
||||
// ServerMode specifies the mode of a running web.Server.
|
||||
type ServerMode string
|
||||
|
||||
const (
|
||||
// LoginServerMode serves a readonly login client for logging a
|
||||
// node into a tailnet, and viewing a readonly interface of the
|
||||
// node's current Tailscale settings.
|
||||
//
|
||||
// In this mode, API calls are authenticated via platform auth.
|
||||
LoginServerMode ServerMode = "login"
|
||||
|
||||
// ManageServerMode serves a management client for editing tailscale
|
||||
// settings of a node.
|
||||
//
|
||||
// This mode restricts the app to only being assessible over Tailscale,
|
||||
// and API calls are authenticated via browser sessions associated with
|
||||
// the source's Tailscale identity. If the source browser does not have
|
||||
// a valid session, a readonly version of the app is displayed.
|
||||
ManageServerMode ServerMode = "manage"
|
||||
|
||||
// LegacyServerMode serves the legacy web client, visible to users
|
||||
// prior to release of tailscale/corp#14335.
|
||||
LegacyServerMode ServerMode = "legacy"
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
// Mode specifies the mode of web client being constructed.
|
||||
Mode ServerMode
|
||||
|
||||
// CGIMode indicates if the server is running as a CGI script.
|
||||
CGIMode bool
|
||||
|
||||
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
|
||||
PathPrefix string
|
||||
|
||||
// LocalClient is the tailscale.LocalClient to use for this web server.
|
||||
// If nil, a new one will be created.
|
||||
LocalClient *tailscale.LocalClient
|
||||
|
||||
// TimeNow optionally provides a time function.
|
||||
// time.Now is used as default.
|
||||
TimeNow func() time.Time
|
||||
|
||||
// Logf optionally provides a logger function.
|
||||
// log.Printf is used as default.
|
||||
Logf logger.Logf
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
// If err is empty, s is always non-nil.
|
||||
// ctx is only required to live the duration of the NewServer call,
|
||||
// and not the lifespan of the web server.
|
||||
func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
switch opts.Mode {
|
||||
case LoginServerMode, ManageServerMode, LegacyServerMode:
|
||||
// valid types
|
||||
case "":
|
||||
return nil, fmt.Errorf("must specify a Mode")
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid Mode provided")
|
||||
}
|
||||
if opts.LocalClient == nil {
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
mode: opts.Mode,
|
||||
logf: opts.Logf,
|
||||
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
timeNow: opts.TimeNow,
|
||||
}
|
||||
if s.timeNow == nil {
|
||||
s.timeNow = time.Now
|
||||
}
|
||||
if s.logf == nil {
|
||||
s.logf = log.Printf
|
||||
}
|
||||
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
|
||||
|
||||
var metric string // clientmetric to report on startup
|
||||
|
||||
// Create handler for "/api" requests with CSRF protection.
|
||||
// We don't require secure cookies, since the web client is regularly used
|
||||
// on network appliances that are served on local non-https URLs.
|
||||
// The client is secured by limiting the interface it listens on,
|
||||
// or by authenticating requests before they reach the web client.
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
if s.mode == LoginServerMode {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
metric = "web_login_client_initialization"
|
||||
} else {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
||||
metric = "web_client_initialization"
|
||||
}
|
||||
|
||||
// Don't block startup on reporting metric.
|
||||
// Report in separate go routine with 5 second timeout.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.lc.IncrementCounter(ctx, metric, 1)
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() {
|
||||
s.logf("web.Server: shutting down")
|
||||
if s.assetsCleanup != nil {
|
||||
s.assetsCleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP processes all requests for the Tailscale web client.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handler := s.serve
|
||||
|
||||
// if path prefix is defined, strip it from requests.
|
||||
if s.pathPrefix != "" {
|
||||
handler = enforcePrefix(s.pathPrefix, handler)
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mode == ManageServerMode {
|
||||
// In manage mode, requests must be sent directly to the bare Tailscale IP address.
|
||||
// If a request comes in on any other hostname, redirect.
|
||||
if s.requireTailscaleIP(w, r) {
|
||||
return // user was redirected
|
||||
}
|
||||
|
||||
// serve HTTP 204 on /ok requests as connectivity check
|
||||
if r.Method == httpm.GET && r.URL.Path == "/ok" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.devMode {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
// TODO: use CSP nonce or hash to eliminate need for unsafe-inline
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
switch {
|
||||
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
|
||||
s.serveAPIAuth(w, r) // serve auth status
|
||||
return
|
||||
case r.URL.Path == "/api/auth/session/new" && r.Method == httpm.GET:
|
||||
s.serveAPIAuthSessionNew(w, r) // create new session
|
||||
return
|
||||
case r.URL.Path == "/api/auth/session/wait" && r.Method == httpm.GET:
|
||||
s.serveAPIAuthSessionWait(w, r) // wait for session to be authorized
|
||||
return
|
||||
}
|
||||
if ok := s.authorizeRequest(w, r); !ok {
|
||||
http.Error(w, "not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Pass API requests through to the API handler.
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// requireTailscaleIP redirects an incoming request if the HTTP request was not made to a bare Tailscale IP address.
|
||||
// The request will be redirected to the Tailscale IP, port 5252, with the original request path.
|
||||
// This allows any custom hostname to be used to access the device, but protects against DNS rebinding attacks.
|
||||
// Returns true if the request has been fully handled, either be returning a redirect or an HTTP error.
|
||||
func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (handled bool) {
|
||||
const (
|
||||
ipv4ServiceHost = tsaddr.TailscaleServiceIPString
|
||||
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
|
||||
)
|
||||
// allow requests on quad-100 (or ipv6 equivalent)
|
||||
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
|
||||
return false
|
||||
}
|
||||
|
||||
st, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
if err != nil {
|
||||
s.logf("error getting status: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
|
||||
var ipv4 string // store the first IPv4 address we see for redirect later
|
||||
for _, ip := range st.Self.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
ipv4 = ip.String()
|
||||
}
|
||||
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
newURL := *r.URL
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
|
||||
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
// is authorized to be completed.
|
||||
// It reports true if the request is authorized, and false otherwise.
|
||||
// authorizeRequest manages writing out any relevant authorization
|
||||
// errors to the ResponseWriter itself.
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.mode == ManageServerMode { // client using tailscale auth
|
||||
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
// All requests must be made over tailscale.
|
||||
http.Error(w, "must access over tailscale", http.StatusUnauthorized)
|
||||
return false
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without browser session.
|
||||
return true
|
||||
case strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
// All other /api/ endpoints require a valid browser session.
|
||||
//
|
||||
// TODO(sonia): s.getSession calls whois again,
|
||||
// should try and use the above call instead of running another
|
||||
// localapi request.
|
||||
session, _, err := s.getSession(r)
|
||||
if err != nil || !session.isAuthorized(s.timeNow()) {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
// No additional auth on non-api (assets, index.html, etc).
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Client using system-specific auth.
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
resp, _ := authorizeSynology(r)
|
||||
return resp.OK
|
||||
case distro.QNAP:
|
||||
resp, _ := authorizeQNAP(r)
|
||||
return resp.OK
|
||||
default:
|
||||
return true // no additional auth for this distro
|
||||
}
|
||||
}
|
||||
|
||||
// serveLoginAPI serves requests for the web login client.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
if r.URL.Path != "/api/data" { // only endpoint allowed for login client
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
// TODO(soniaappasamy): we may want a minimal node data response here
|
||||
s.serveGetNodeData(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
type authType string
|
||||
|
||||
var (
|
||||
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
|
||||
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
|
||||
)
|
||||
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"` // true when user has valid auth session
|
||||
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
||||
}
|
||||
|
||||
// serverAPIAuth handles requests to the /api/auth endpoint
|
||||
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
|
||||
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var resp authResponse
|
||||
|
||||
session, _, err := s.getSession(r)
|
||||
switch {
|
||||
case err != nil && errors.Is(err, errNotUsingTailscale):
|
||||
// not using tailscale, so perform platform auth
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
resp, err = authorizeSynology(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
case distro.QNAP:
|
||||
resp, err = authorizeQNAP(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
default:
|
||||
resp.OK = true // no additional auth for this distro
|
||||
}
|
||||
case err != nil && (errors.Is(err, errNotOwner) ||
|
||||
errors.Is(err, errNotUsingTailscale) ||
|
||||
errors.Is(err, errTaggedLocalSource) ||
|
||||
errors.Is(err, errTaggedRemoteSource)):
|
||||
// These cases are all restricted to the readonly view.
|
||||
// No auth action to take.
|
||||
resp = authResponse{OK: false}
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
case session.isAuthorized(s.timeNow()):
|
||||
resp = authResponse{OK: true}
|
||||
default:
|
||||
resp = authResponse{OK: false, AuthNeeded: tailscaleAuth}
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
type newSessionAuthResponse struct {
|
||||
AuthURL string `json:"authUrl,omitempty"`
|
||||
}
|
||||
|
||||
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
||||
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
||||
session, whois, err := s.getSession(r)
|
||||
if err != nil && !errors.Is(err, errNoSession) {
|
||||
// Source associated with request not allowed to create
|
||||
// a session for this web client.
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
// Create a new session.
|
||||
// If one already existed, we return that authURL rather than creating a new one.
|
||||
session, err = s.newSession(r.Context(), whois)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, newSessionAuthResponse{AuthURL: session.AuthURL})
|
||||
}
|
||||
|
||||
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
||||
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
||||
session, _, err := s.getSession(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return // already authorized
|
||||
}
|
||||
if err := s.awaitUserAuth(r.Context(), session); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
s.serveGetNodeData(w, r)
|
||||
case httpm.POST:
|
||||
s.servePostNodeUpdate(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
case strings.HasPrefix(path, "/local/"):
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
Profile tailcfg.UserProfile
|
||||
Status string
|
||||
DeviceName string
|
||||
IP string
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes string
|
||||
LicensesURL string
|
||||
TUNMode bool
|
||||
IsSynology bool
|
||||
DSMVersion int // 6 or 7, if IsSynology=true
|
||||
IsUnraid bool
|
||||
UnraidToken string
|
||||
IPNVersion string
|
||||
DebugMode string // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.lc.Status(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profile := st.User[st.Self.UserID]
|
||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||
versionShort := strings.Split(st.Version, "-")[0]
|
||||
var debugMode string
|
||||
if s.mode == ManageServerMode {
|
||||
debugMode = "full"
|
||||
} else if s.mode == LoginServerMode {
|
||||
debugMode = "login"
|
||||
}
|
||||
data := &nodeData{
|
||||
Profile: profile,
|
||||
Status: st.BackendState,
|
||||
DeviceName: deviceName,
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
TUNMode: st.TUN,
|
||||
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
||||
DSMVersion: distro.DSMVersion(),
|
||||
IsUnraid: distro.Get() == distro.Unraid,
|
||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||
IPNVersion: versionShort,
|
||||
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
||||
}
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertiseExitNode = true
|
||||
} else {
|
||||
if data.AdvertiseRoutes != "" {
|
||||
data.AdvertiseRoutes += ","
|
||||
}
|
||||
data.AdvertiseRoutes += r.String()
|
||||
}
|
||||
}
|
||||
if len(st.TailscaleIPs) != 0 {
|
||||
data.IP = st.TailscaleIPs[0].String()
|
||||
}
|
||||
writeJSON(w, *data)
|
||||
}
|
||||
|
||||
type nodeUpdate struct {
|
||||
AdvertiseRoutes string
|
||||
AdvertiseExitNode bool
|
||||
Reauthenticate bool
|
||||
ForceLogout bool
|
||||
}
|
||||
|
||||
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
st, err := s.lc.Status(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var postData nodeUpdate
|
||||
type mi map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
|
||||
w.WriteHeader(400)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
isCurrentlyExitNode := slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV4) || slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV6)
|
||||
|
||||
if postData.AdvertiseExitNode != isCurrentlyExitNode {
|
||||
if postData.AdvertiseExitNode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||
}
|
||||
}
|
||||
|
||||
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mp := &ipn.MaskedPrefs{
|
||||
AdvertiseRoutesSet: true,
|
||||
WantRunningSet: true,
|
||||
}
|
||||
mp.Prefs.WantRunning = true
|
||||
mp.Prefs.AdvertiseRoutes = routes
|
||||
s.logf("Doing edit: %v", mp.Pretty())
|
||||
|
||||
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var reauth, logout bool
|
||||
if postData.Reauthenticate {
|
||||
reauth = true
|
||||
}
|
||||
if postData.ForceLogout {
|
||||
logout = true
|
||||
}
|
||||
s.logf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
url, err := s.tailscaleUp(r.Context(), st, postData)
|
||||
s.logf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if url != "" {
|
||||
json.NewEncoder(w).Encode(mi{"url": url})
|
||||
} else {
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
|
||||
if postData.ForceLogout {
|
||||
if err := s.lc.Logout(ctx); err != nil {
|
||||
return "", fmt.Errorf("Logout error: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
origAuthURL := st.AuthURL
|
||||
isRunning := st.BackendState == ipn.Running.String()
|
||||
|
||||
forceReauth := postData.Reauthenticate
|
||||
if !forceReauth {
|
||||
if origAuthURL != "" {
|
||||
return origAuthURL, nil
|
||||
}
|
||||
if isRunning {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// printAuthURL reports whether we should print out the
|
||||
// provided auth URL from an IPN notify.
|
||||
printAuthURL := func(url string) bool {
|
||||
return url != origAuthURL
|
||||
}
|
||||
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
go func() {
|
||||
if !isRunning {
|
||||
s.lc.Start(ctx, ipn.Options{})
|
||||
}
|
||||
if forceReauth {
|
||||
s.lc.StartLoginInteractive(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.ErrMessage != nil {
|
||||
msg := *n.ErrMessage
|
||||
return "", fmt.Errorf("backend error: %v", msg)
|
||||
}
|
||||
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
|
||||
return *url, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyRequestToLocalAPI proxies the web API request to the localapi.
|
||||
//
|
||||
// The web API request path is expected to exactly match a localapi path,
|
||||
// with prefix /api/local/ rather than /localapi/.
|
||||
//
|
||||
// If the localapi path is not included in localapiAllowlist,
|
||||
// the request is rejected.
|
||||
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/local")
|
||||
if r.URL.Path == path { // missing prefix
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(localapiAllowlist, path) {
|
||||
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to construct request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Make request to tailscaled localapi.
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Send response back to web frontend.
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// localapiAllowlist is an allowlist of localapi endpoints the
|
||||
// web client is allowed to proxy to the client's localapi.
|
||||
//
|
||||
// Rather than exposing all localapi endpoints over the proxy,
|
||||
// this limits to just the ones actually used from the web
|
||||
// client frontend.
|
||||
//
|
||||
// TODO(sonia,will): Shouldn't expand this beyond the existing
|
||||
// localapi endpoints until the larger web client auth story
|
||||
// is worked out (tailscale/corp#14335).
|
||||
var localapiAllowlist = []string{
|
||||
"/v0/logout",
|
||||
}
|
||||
|
||||
// csrfKey returns a key that can be used for CSRF protection.
|
||||
// If an error occurs during key creation, the error is logged and the active process terminated.
|
||||
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|
||||
// If an error occurs during key storage, the error is logged and the active process terminated.
|
||||
func (s *Server) csrfKey() []byte {
|
||||
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
|
||||
|
||||
// if running in CGI mode, try to read from disk, but ignore errors
|
||||
if s.cgiMode {
|
||||
key, _ := os.ReadFile(csrfFile)
|
||||
if len(key) == 32 {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
// create a new key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatalf("error generating CSRF key: %v", err)
|
||||
}
|
||||
|
||||
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
|
||||
if s.cgiMode {
|
||||
if err := os.WriteFile(csrfFile, key, 0600); err != nil {
|
||||
log.Fatalf("unable to store CSRF key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests,
|
||||
// then strips it before invoking h.
|
||||
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
|
||||
// Instead, it returns a redirect to the prefix path.
|
||||
func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
|
||||
if prefix == "" {
|
||||
return h
|
||||
}
|
||||
|
||||
// ensure that prefix always has both a leading and trailing slash so
|
||||
// that relative links for JS and CSS assets work correctly.
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
http.Redirect(w, r, prefix, http.StatusFound)
|
||||
return
|
||||
}
|
||||
prefix = strings.TrimSuffix(prefix, "/")
|
||||
http.StripPrefix(prefix, h).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/memnet"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
func TestQnapAuthnURL(t *testing.T) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{"token"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "localhost http",
|
||||
in: "http://localhost:8088/",
|
||||
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "localhost https",
|
||||
in: "https://localhost:5000/",
|
||||
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP http",
|
||||
in: "http://10.1.20.4:80/",
|
||||
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "IP6 https",
|
||||
in: "https://[ff7d:0:1:2::1]/",
|
||||
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "hostname https",
|
||||
in: "https://qnap.example.com/",
|
||||
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
{
|
||||
name: "err != nil",
|
||||
in: "http://192.168.0.%31/",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u := qnapAuthnURL(tt.in, query)
|
||||
if u != tt.want {
|
||||
t.Errorf("expected url: %q, got: %q", tt.want, u)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeAPI tests the web client api's handling of
|
||||
// 1. invalid endpoint errors
|
||||
// 2. localapi proxy allowlist
|
||||
func TestServeAPI(t *testing.T) {
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
// Serve dummy localapi. Just returns "success".
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "success")
|
||||
})}
|
||||
defer localapi.Close()
|
||||
|
||||
go localapi.Serve(lal)
|
||||
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reqPath string
|
||||
wantResp string
|
||||
wantStatus int
|
||||
}{{
|
||||
name: "invalid_endpoint",
|
||||
reqPath: "/not-an-endpoint",
|
||||
wantResp: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
name: "not_in_localapi_allowlist",
|
||||
reqPath: "/local/v0/not-allowlisted",
|
||||
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
|
||||
wantStatus: http.StatusForbidden,
|
||||
}, {
|
||||
name: "in_localapi_allowlist",
|
||||
reqPath: "/local/v0/logout",
|
||||
wantResp: "success", // Successfully allowed to hit localapi.
|
||||
wantStatus: http.StatusOK,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api"+tt.reqPath, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.serveAPI(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||
if tt.wantResp != gotResp {
|
||||
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)}
|
||||
|
||||
userANodeIP := "100.100.100.101"
|
||||
userBNodeIP := "100.100.100.102"
|
||||
taggedNodeIP := "100.100.100.103"
|
||||
|
||||
var selfNode *ipnstate.PeerStatus
|
||||
tags := views.SliceOf([]string{"tag:server"})
|
||||
tailnetNodes := map[string]*apitype.WhoIsResponse{
|
||||
userANodeIP: {
|
||||
Node: &tailcfg.Node{ID: 1, StableID: "1"},
|
||||
UserProfile: userA,
|
||||
},
|
||||
userBNodeIP: {
|
||||
Node: &tailcfg.Node{ID: 2, StableID: "2"},
|
||||
UserProfile: userB,
|
||||
},
|
||||
taggedNodeIP: {
|
||||
Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()},
|
||||
},
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode })
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
timeNow: time.Now,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
}
|
||||
|
||||
// Add some browser sessions to cache state.
|
||||
userASession := &browserSession{
|
||||
ID: "cookie1",
|
||||
SrcNode: 1,
|
||||
SrcUser: userA.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: false, // not yet authenticated
|
||||
}
|
||||
userBSession := &browserSession{
|
||||
ID: "cookie2",
|
||||
SrcNode: 2,
|
||||
SrcUser: userB.ID,
|
||||
Created: time.Now().Add(-2 * sessionCookieExpiry),
|
||||
Authenticated: true, // expired
|
||||
}
|
||||
userASessionAuthorized := &browserSession{
|
||||
ID: "cookie3",
|
||||
SrcNode: 1,
|
||||
SrcUser: userA.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: true, // authenticated and not expired
|
||||
}
|
||||
s.browserSessions.Store(userASession.ID, userASession)
|
||||
s.browserSessions.Store(userBSession.ID, userBSession)
|
||||
s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
selfNode *ipnstate.PeerStatus
|
||||
remoteAddr string
|
||||
cookie string
|
||||
|
||||
wantSession *browserSession
|
||||
wantError error
|
||||
wantIsAuthorized bool // response from session.isAuthorized
|
||||
}{
|
||||
{
|
||||
name: "not-connected-over-tailscale",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: "77.77.77.77",
|
||||
wantSession: nil,
|
||||
wantError: errNotUsingTailscale,
|
||||
},
|
||||
{
|
||||
name: "no-session-user-self-node",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP,
|
||||
cookie: "not-a-cookie",
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
{
|
||||
name: "no-session-tagged-self-node",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags},
|
||||
remoteAddr: userANodeIP,
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
{
|
||||
name: "not-owner",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userBNodeIP,
|
||||
wantSession: nil,
|
||||
wantError: errNotOwner,
|
||||
},
|
||||
{
|
||||
name: "tagged-remote-source",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: taggedNodeIP,
|
||||
wantSession: nil,
|
||||
wantError: errTaggedRemoteSource,
|
||||
},
|
||||
{
|
||||
name: "tagged-local-source",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "3"},
|
||||
remoteAddr: taggedNodeIP, // same node as selfNode
|
||||
wantSession: nil,
|
||||
wantError: errTaggedLocalSource,
|
||||
},
|
||||
{
|
||||
name: "not-tagged-local-source",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP, // same node as selfNode
|
||||
cookie: userASession.ID,
|
||||
wantSession: userASession,
|
||||
wantError: nil, // should not error
|
||||
},
|
||||
{
|
||||
name: "has-session",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP,
|
||||
cookie: userASession.ID,
|
||||
wantSession: userASession,
|
||||
wantError: nil,
|
||||
},
|
||||
{
|
||||
name: "has-authorized-session",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
|
||||
remoteAddr: userANodeIP,
|
||||
cookie: userASessionAuthorized.ID,
|
||||
wantSession: userASessionAuthorized,
|
||||
wantError: nil,
|
||||
wantIsAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "session-associated-with-different-source",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
|
||||
remoteAddr: userBNodeIP,
|
||||
cookie: userASession.ID,
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
{
|
||||
name: "session-expired",
|
||||
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
|
||||
remoteAddr: userBNodeIP,
|
||||
cookie: userBSession.ID,
|
||||
wantSession: nil,
|
||||
wantError: errNoSession,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selfNode = tt.selfNode
|
||||
r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}}
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
}
|
||||
session, _, err := s.getSession(r)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||
}
|
||||
if diff := cmp.Diff(session, tt.wantSession); diff != "" {
|
||||
t.Errorf("wrong session; (-got+want):%v", diff)
|
||||
}
|
||||
if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized {
|
||||
t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizeRequest tests the s.authorizeRequest function.
|
||||
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
|
||||
func TestAuthorizeRequest(t *testing.T) {
|
||||
// Create self and remoteNode owned by same user.
|
||||
// See TestGetTailscaleBrowserSession for tests of
|
||||
// browser sessions w/ different users.
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
}
|
||||
validCookie := "ts-cookie"
|
||||
s.browserSessions.Store(validCookie, &browserSession{
|
||||
ID: validCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: time.Now(),
|
||||
Authenticated: true,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
reqPath string
|
||||
reqMethod string
|
||||
|
||||
wantOkNotOverTailscale bool // simulates req over public internet
|
||||
wantOkWithoutSession bool // simulates req over TS without valid browser session
|
||||
wantOkWithSession bool // simulates req over TS with valid browser session
|
||||
}{{
|
||||
reqPath: "/api/data",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/data",
|
||||
reqMethod: httpm.POST,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/somethingelse",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/assets/styles.css",
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
|
||||
doAuthorize := func(remoteAddr string, cookie string) bool {
|
||||
r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
|
||||
r.RemoteAddr = remoteAddr
|
||||
if cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
return s.authorizeRequest(w, r)
|
||||
}
|
||||
// Do request from non-Tailscale IP.
|
||||
if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
|
||||
t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
|
||||
}
|
||||
// Do request from Tailscale IP w/o associated session.
|
||||
if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
|
||||
t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
|
||||
}
|
||||
// Do request from Tailscale IP w/ associated session.
|
||||
if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
|
||||
t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeAuth(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{
|
||||
ID: "self",
|
||||
UserID: user.ID,
|
||||
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
|
||||
}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
timeNow := time.Now()
|
||||
oneHourAgo := timeNow.Add(-time.Hour)
|
||||
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
}
|
||||
|
||||
successCookie := "ts-cookie-success"
|
||||
s.browserSessions.Store(successCookie, &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
})
|
||||
failureCookie := "ts-cookie-failure"
|
||||
s.browserSessions.Store(failureCookie, &browserSession{
|
||||
ID: failureCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathError,
|
||||
AuthURL: testControlURL + testAuthPathError,
|
||||
})
|
||||
expiredCookie := "ts-cookie-expired"
|
||||
s.browserSessions.Store(expiredCookie, &browserSession{
|
||||
ID: expiredCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: sixtyDaysAgo,
|
||||
AuthID: "/a/old-auth-url",
|
||||
AuthURL: testControlURL + "/a/old-auth-url",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
cookie string // cookie attached to request
|
||||
wantNewCookie bool // want new cookie generated during request
|
||||
wantSession *browserSession // session associated w/ cookie after request
|
||||
|
||||
path string
|
||||
wantStatus int
|
||||
wantResp any
|
||||
}{
|
||||
{
|
||||
name: "no-session",
|
||||
path: "/api/auth",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
|
||||
wantNewCookie: false,
|
||||
wantSession: nil,
|
||||
},
|
||||
{
|
||||
name: "new-session",
|
||||
path: "/api/auth/session/new",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID", // gets swapped for newly created ID by test
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query-existing-incomplete-session",
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "existing-session-used",
|
||||
path: "/api/auth/session/new", // should not create new session
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPathSuccess},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-successful-session",
|
||||
path: "/api/auth/session/wait",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: nil,
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query-existing-complete-session",
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-failed-session",
|
||||
path: "/api/auth/session/wait",
|
||||
cookie: failureCookie,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantResp: nil,
|
||||
wantSession: nil, // session deleted
|
||||
},
|
||||
{
|
||||
name: "failed-session-cleaned-up",
|
||||
path: "/api/auth/session/new",
|
||||
cookie: failureCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired-cookie-gets-new-session",
|
||||
path: "/api/auth/session/new",
|
||||
cookie: expiredCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: timeNow,
|
||||
AuthID: testAuthPath,
|
||||
AuthURL: testControlURL + testAuthPath,
|
||||
Authenticated: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
|
||||
r.RemoteAddr = remoteIP
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.serve(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
// Validate response status/data.
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
var gotResp string
|
||||
if res.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp = strings.Trim(string(body), "\n")
|
||||
}
|
||||
var wantResp string
|
||||
if tt.wantResp != nil {
|
||||
b, _ := json.Marshal(tt.wantResp)
|
||||
wantResp = string(b)
|
||||
}
|
||||
if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
|
||||
t.Errorf("wrong response; (-got+want):%v", diff)
|
||||
}
|
||||
// Validate cookie creation.
|
||||
sessionID := tt.cookie
|
||||
var gotCookie bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
gotCookie = true
|
||||
sessionID = c.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotCookie != tt.wantNewCookie {
|
||||
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
|
||||
}
|
||||
// Validate browser session contents.
|
||||
var gotSesson *browserSession
|
||||
if s, ok := s.browserSessions.Load(sessionID); ok {
|
||||
gotSesson = s.(*browserSession)
|
||||
}
|
||||
if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
|
||||
// If requested, swap in the generated session ID before
|
||||
// comparing got/want.
|
||||
tt.wantSession.ID = sessionID
|
||||
}
|
||||
if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
|
||||
t.Errorf("wrong session; (-got+want):%v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireTailscaleIP(t *testing.T) {
|
||||
self := &ipnstate.PeerStatus{
|
||||
TailscaleIPs: []netip.Addr{
|
||||
netip.MustParseAddr("100.1.2.3"),
|
||||
netip.MustParseAddr("fd7a:115c::1234"),
|
||||
},
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self })
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
logf: t.Logf,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
wantHandled bool
|
||||
wantLocation string
|
||||
}{
|
||||
{
|
||||
name: "localhost",
|
||||
target: "http://localhost/",
|
||||
wantHandled: true,
|
||||
wantLocation: "http://100.1.2.3:5252/",
|
||||
},
|
||||
{
|
||||
name: "ipv4-no-port",
|
||||
target: "http://100.1.2.3/",
|
||||
wantHandled: true,
|
||||
wantLocation: "http://100.1.2.3:5252/",
|
||||
},
|
||||
{
|
||||
name: "ipv4-correct-port",
|
||||
target: "http://100.1.2.3:5252/",
|
||||
wantHandled: false,
|
||||
},
|
||||
{
|
||||
name: "ipv6-no-port",
|
||||
target: "http://[fd7a:115c::1234]/",
|
||||
wantHandled: true,
|
||||
wantLocation: "http://100.1.2.3:5252/",
|
||||
},
|
||||
{
|
||||
name: "ipv6-correct-port",
|
||||
target: "http://[fd7a:115c::1234]:5252/",
|
||||
wantHandled: false,
|
||||
},
|
||||
{
|
||||
name: "quad-100",
|
||||
target: "http://100.100.100.100/",
|
||||
wantHandled: false,
|
||||
},
|
||||
{
|
||||
name: "ipv6-service-addr",
|
||||
target: "http://[fd7a:115c:a1e0::53]/",
|
||||
wantHandled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.target, func(t *testing.T) {
|
||||
s.logf = t.Logf
|
||||
r := httptest.NewRequest(httpm.GET, tt.target, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handled := s.requireTailscaleIP(w, r)
|
||||
|
||||
if handled != tt.wantHandled {
|
||||
t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != tt.wantLocation {
|
||||
t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
testControlURL = "http://localhost:8080"
|
||||
testAuthPath = "/a/12345"
|
||||
testAuthPathSuccess = "/a/will-succeed"
|
||||
testAuthPathError = "/a/will-error"
|
||||
)
|
||||
|
||||
// mockLocalAPI constructs a test localapi handler that can be used
|
||||
// to simulate localapi responses without a functioning tailnet.
|
||||
//
|
||||
// self accepts a function that resolves to a self node status,
|
||||
// so that tests may swap out the /localapi/v0/status response
|
||||
// as desired.
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server {
|
||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
addr := r.URL.Query().Get("addr")
|
||||
if addr == "" {
|
||||
t.Fatalf("/whois call missing \"addr\" query")
|
||||
}
|
||||
if node := whoIs[addr]; node != nil {
|
||||
writeJSON(w, &node)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not a node", http.StatusUnauthorized)
|
||||
return
|
||||
case "/localapi/v0/status":
|
||||
writeJSON(w, ipnstate.Status{Self: self()})
|
||||
return
|
||||
case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
|
||||
type reqData struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var data reqData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if data.Src == 0 {
|
||||
http.Error(w, "missing Src node", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var resp *tailcfg.WebClientAuthResponse
|
||||
if data.ID == "" {
|
||||
resp = &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath}
|
||||
} else if data.ID == testAuthPathSuccess {
|
||||
resp = &tailcfg.WebClientAuthResponse{Complete: true}
|
||||
} else if data.ID == testAuthPathError {
|
||||
http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
})}
|
||||
}
|
||||
1878
client/web/yarn.lock
1878
client/web/yarn.lock
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,797 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toTrack string
|
||||
in string
|
||||
want string // empty means want no change
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "stable-to-unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
|
||||
want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "stable-unchanged",
|
||||
toTrack: StableTrack,
|
||||
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "if-both-stable-and-unstable-dont-change",
|
||||
toTrack: StableTrack,
|
||||
in: "# Tailscale packages for debian buster\n" +
|
||||
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
|
||||
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "if-both-stable-and-unstable-dont-change-unstable",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for debian buster\n" +
|
||||
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
|
||||
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
|
||||
},
|
||||
{
|
||||
name: "signed-by-form",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
|
||||
want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
|
||||
},
|
||||
{
|
||||
name: "unsupported-lines",
|
||||
toTrack: UnstableTrack,
|
||||
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
|
||||
wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack)
|
||||
if err != nil {
|
||||
if err.Error() != tt.wantErr {
|
||||
t.Fatalf("error = %v; want %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
t.Fatalf("got no error; want %q", tt.wantErr)
|
||||
}
|
||||
var gotChange string
|
||||
if string(newContent) != tt.in {
|
||||
gotChange = string(newContent)
|
||||
}
|
||||
if gotChange != tt.want {
|
||||
t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateYUMRepoTrack(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
before string
|
||||
track string
|
||||
after string
|
||||
rewrote bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "same track",
|
||||
before: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
track: StableTrack,
|
||||
after: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
},
|
||||
{
|
||||
desc: "change track",
|
||||
before: `
|
||||
[tailscale-stable]
|
||||
name=Tailscale stable
|
||||
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
|
||||
`,
|
||||
track: UnstableTrack,
|
||||
after: `
|
||||
[tailscale-unstable]
|
||||
name=Tailscale unstable
|
||||
baseurl=https://pkgs.tailscale.com/unstable/fedora/$basearch
|
||||
enabled=1
|
||||
type=rpm
|
||||
repo_gpgcheck=1
|
||||
gpgcheck=0
|
||||
gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg
|
||||
`,
|
||||
rewrote: true,
|
||||
},
|
||||
{
|
||||
desc: "non-tailscale repo file",
|
||||
before: `
|
||||
[fedora]
|
||||
name=Fedora $releasever - $basearch
|
||||
#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/
|
||||
metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch
|
||||
enabled=1
|
||||
countme=1
|
||||
metadata_expire=7d
|
||||
repo_gpgcheck=0
|
||||
type=rpm
|
||||
gpgcheck=1
|
||||
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
|
||||
skip_if_unavailable=False
|
||||
`,
|
||||
track: StableTrack,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tailscale.repo")
|
||||
if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rewrote, err := updateYUMRepoTrack(path, tt.track)
|
||||
if err == nil && tt.wantErr {
|
||||
t.Fatal("got nil error, want non-nil")
|
||||
}
|
||||
if err != nil && !tt.wantErr {
|
||||
t.Fatalf("got error %q, want nil", err)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if rewrote != tt.rewrote {
|
||||
t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote)
|
||||
}
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != tt.after {
|
||||
t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAlpinePackageVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
out string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "valid version",
|
||||
out: `
|
||||
tailscale-1.44.2-r0 description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale-1.44.2-r0 webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale-1.44.2-r0 installed size:
|
||||
32 MiB
|
||||
`,
|
||||
want: "1.44.2",
|
||||
},
|
||||
{
|
||||
desc: "wrong package output",
|
||||
out: `
|
||||
busybox-1.36.1-r0 description:
|
||||
Size optimized toolbox of many common UNIX utilities
|
||||
|
||||
busybox-1.36.1-r0 webpage:
|
||||
https://busybox.net/
|
||||
|
||||
busybox-1.36.1-r0 installed size:
|
||||
924 KiB
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "missing version",
|
||||
out: `
|
||||
tailscale description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale installed size:
|
||||
32 MiB
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty output",
|
||||
out: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got, err := parseAlpinePackageVersion([]byte(tt.out))
|
||||
if err == nil && tt.wantErr {
|
||||
t.Fatalf("got nil error and version %q, want non-nil error", got)
|
||||
}
|
||||
if err != nil && !tt.wantErr {
|
||||
t.Fatalf("got error: %q, want nil", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got version: %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynoArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
goarch string
|
||||
synoinfoUnique string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{goarch: "amd64", synoinfoUnique: "synology_x86_224", want: "x86_64"},
|
||||
{goarch: "arm64", synoinfoUnique: "synology_armv8_124", want: "armv8"},
|
||||
{goarch: "386", synoinfoUnique: "synology_i686_415play", want: "i686"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_88f6281_213air", want: "88f6281"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_88f6282_413j", want: "88f6282"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_hi3535_NVR1218", want: "hi3535"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_alpine_1517", want: "alpine"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armada370_216se", want: "armada370"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armada375_115", want: "armada375"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armada38x_419slim", want: "armada38x"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_armadaxp_RS815", want: "armadaxp"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_comcerto2k_414j", want: "comcerto2k"},
|
||||
{goarch: "arm", synoinfoUnique: "synology_monaco_216play", want: "monaco"},
|
||||
{goarch: "ppc64", synoinfoUnique: "synology_qoriq_413", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.synoinfoUnique), func(t *testing.T) {
|
||||
synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf")
|
||||
if err := os.WriteFile(
|
||||
synoinfoConfPath,
|
||||
[]byte(fmt.Sprintf("unique=%q\n", tt.synoinfoUnique)),
|
||||
0600,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := synoArch(tt.goarch, synoinfoConfPath)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("got unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("got %q, expected an error", got)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSynoinfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
content string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "double-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique="synology_88f6281_213air"
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
{
|
||||
desc: "single-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique='synology_88f6281_213air'
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
{
|
||||
desc: "unquoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=synology_88f6281_213air
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
{
|
||||
desc: "missing unique",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty unique",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty unique double-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=""
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty unique single-quoted",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique=''
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "malformed unique",
|
||||
content: `
|
||||
company_title="Synology"
|
||||
unique="synology_88f6281"
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty file",
|
||||
content: ``,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty lines and comments",
|
||||
content: `
|
||||
|
||||
# In a file named synoinfo? Shocking!
|
||||
company_title="Synology"
|
||||
|
||||
|
||||
# unique= is_a_field_that_follows
|
||||
unique="synology_88f6281_213air"
|
||||
|
||||
`,
|
||||
want: "88f6281",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
synoinfoConfPath := filepath.Join(t.TempDir(), "synoinfo.conf")
|
||||
if err := os.WriteFile(synoinfoConfPath, []byte(tt.content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := parseSynoinfo(synoinfoConfPath)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("got unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("got %q, expected an error", got)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackLinuxTarball(t *testing.T) {
|
||||
oldBinaryPaths := binaryPaths
|
||||
t.Cleanup(func() { binaryPaths = oldBinaryPaths })
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
tarball map[string]string
|
||||
before map[string]string
|
||||
after map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "success",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v2",
|
||||
"tailscaled": "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "don't touch unrelated files",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
"foo": "bar",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v2",
|
||||
"tailscaled": "v2",
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "unmodified",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v1",
|
||||
"/usr/bin/tailscaled": "v1",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "ignore extra tarball files",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
"/systemd/tailscaled.service": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v2",
|
||||
"tailscaled": "v2",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "tarball missing tailscaled",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscale.new": "v2",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "duplicate tailscale binary",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{
|
||||
"/usr/bin/tailscale": "v2",
|
||||
"/usr/sbin/tailscale": "v2",
|
||||
"/usr/bin/tailscaled": "v2",
|
||||
},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscale.new": "v2",
|
||||
"tailscaled": "v1",
|
||||
"tailscaled.new": "v2",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "empty archive",
|
||||
before: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
tarball: map[string]string{},
|
||||
after: map[string]string{
|
||||
"tailscale": "v1",
|
||||
"tailscaled": "v1",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
// Swap out binaryPaths function to point at dummy file paths.
|
||||
tmp := t.TempDir()
|
||||
tailscalePath := filepath.Join(tmp, "tailscale")
|
||||
tailscaledPath := filepath.Join(tmp, "tailscaled")
|
||||
binaryPaths = func() (string, string, error) {
|
||||
return tailscalePath, tailscaledPath, nil
|
||||
}
|
||||
for name, content := range tt.before {
|
||||
if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tarPath := filepath.Join(tmp, "tailscale.tgz")
|
||||
genTarball(t, tarPath, tt.tarball)
|
||||
|
||||
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
|
||||
err := up.unpackLinuxTarball(tarPath)
|
||||
if err != nil {
|
||||
if !tt.wantErr {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
} else if tt.wantErr {
|
||||
t.Fatalf("unpack succeeded, expected an error")
|
||||
}
|
||||
|
||||
gotAfter := make(map[string]string)
|
||||
err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type().IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path == tarPath {
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = filepath.ToSlash(path)
|
||||
base := filepath.ToSlash(tmp)
|
||||
gotAfter[strings.TrimPrefix(path, base+"/")] = string(content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !maps.Equal(gotAfter, tt.after) {
|
||||
t.Errorf("files after unpack: %+v, want %+v", gotAfter, tt.after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func genTarball(t *testing.T, path string, files map[string]string) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gw := gzip.NewWriter(f)
|
||||
defer gw.Close()
|
||||
tw := tar.NewWriter(gw)
|
||||
defer tw.Close()
|
||||
for file, content := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: file,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0755,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test")
|
||||
for i := 0; i < 2; i++ {
|
||||
content := fmt.Sprintf("content %d", i)
|
||||
if err := writeFile(strings.NewReader(content), path, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("got content: %q, want: %q", got, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileSymlink(t *testing.T) {
|
||||
// Test for a malicious symlink at the destination path.
|
||||
// f2 points to f1 and writeFile(f2) should not end up overwriting f1.
|
||||
tmp := t.TempDir()
|
||||
f1 := filepath.Join(tmp, "f1")
|
||||
if err := os.WriteFile(f1, []byte("old"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f2 := filepath.Join(tmp, "f2")
|
||||
if err := os.Symlink(f1, f2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeFile(strings.NewReader("new"), f2, 0600); err != nil {
|
||||
t.Errorf("writeFile(%q) failed: %v", f2, err)
|
||||
}
|
||||
want := map[string]string{
|
||||
f1: "old",
|
||||
f2: "new",
|
||||
}
|
||||
for f, content := range want {
|
||||
got, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("%q: got content %q, want %q", f, got, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldDownloads(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
before []string
|
||||
symlinks map[string]string
|
||||
glob string
|
||||
after []string
|
||||
}{
|
||||
{
|
||||
desc: "MSIs",
|
||||
before: []string{
|
||||
"MSICache/tailscale-1.0.0.msi",
|
||||
"MSICache/tailscale-1.1.0.msi",
|
||||
"MSICache/readme.txt",
|
||||
},
|
||||
glob: "MSICache/*.msi",
|
||||
after: []string{
|
||||
"MSICache/readme.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SPKs",
|
||||
before: []string{
|
||||
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
|
||||
"tmp/tailscale-update-2/tailscale-1.1.0.spk",
|
||||
"tmp/readme.txt",
|
||||
"tmp/tailscale-update-3",
|
||||
"tmp/tailscale-update-4/tailscale-1.3.0",
|
||||
},
|
||||
glob: "tmp/tailscale-update*/*.spk",
|
||||
after: []string{
|
||||
"tmp/readme.txt",
|
||||
"tmp/tailscale-update-3",
|
||||
"tmp/tailscale-update-4/tailscale-1.3.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "empty-target",
|
||||
before: []string{},
|
||||
glob: "tmp/tailscale-update*/*.spk",
|
||||
after: []string{},
|
||||
},
|
||||
{
|
||||
desc: "keep-dirs",
|
||||
before: []string{
|
||||
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
|
||||
},
|
||||
glob: "tmp/tailscale-update*",
|
||||
after: []string{
|
||||
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no-follow-symlinks",
|
||||
before: []string{
|
||||
"MSICache/tailscale-1.0.0.msi",
|
||||
"MSICache/tailscale-1.1.0.msi",
|
||||
"MSICache/readme.txt",
|
||||
},
|
||||
symlinks: map[string]string{
|
||||
"MSICache/tailscale-1.3.0.msi": "MSICache/tailscale-1.0.0.msi",
|
||||
"MSICache/tailscale-1.4.0.msi": "MSICache/readme.txt",
|
||||
},
|
||||
glob: "MSICache/*.msi",
|
||||
after: []string{
|
||||
"MSICache/tailscale-1.3.0.msi",
|
||||
"MSICache/tailscale-1.4.0.msi",
|
||||
"MSICache/readme.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, p := range tt.before {
|
||||
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, p), []byte(tt.desc), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for from, to := range tt.symlinks {
|
||||
if err := os.Symlink(filepath.Join(dir, to), filepath.Join(dir, from)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
|
||||
up.cleanupOldDownloads(filepath.Join(dir, tt.glob))
|
||||
|
||||
var after []string
|
||||
if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if !d.IsDir() {
|
||||
after = append(after, strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(dir)+"/"))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sort.Strings(after)
|
||||
sort.Strings(tt.after)
|
||||
if !slices.Equal(after, tt.after) {
|
||||
t.Errorf("got files after cleanup: %q, want: %q", after, tt.after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Windows-specific stuff that can't go in clientupdate.go because it needs
|
||||
// x/sys/windows.
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
name16 := windows.StringToUTF16Ptr(name)
|
||||
return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
|
||||
}
|
||||
|
||||
const certSubjectTailscale = "Tailscale Inc."
|
||||
|
||||
func verifyTailscale(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
|
||||
func launchTailscaleAsGUIUser(exePath string) error {
|
||||
exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe")
|
||||
|
||||
var token windows.Token
|
||||
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
|
||||
sessionID := winutil.WTSGetActiveConsoleSessionId()
|
||||
if sessionID != 0xFFFFFFFF {
|
||||
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
defer token.Close()
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(exePath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Token: syscall.Token(token),
|
||||
HideWindow: true,
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package distsign implements signature and validation of arbitrary
|
||||
// distributable files.
|
||||
//
|
||||
// There are 3 parties in this exchange:
|
||||
// - builder, which creates files, signs them with signing keys and publishes
|
||||
// to server
|
||||
// - server, which distributes public signing keys, files and signatures
|
||||
// - client, which downloads files and signatures from server, and validates
|
||||
// the signatures
|
||||
//
|
||||
// There are 2 types of keys:
|
||||
// - signing keys, that sign individual distributable files on the builder
|
||||
// - root keys, that sign signing keys and are kept offline
|
||||
//
|
||||
// root keys -(sign)-> signing keys -(sign)-> files
|
||||
//
|
||||
// All keys are asymmetric Ed25519 key pairs.
|
||||
//
|
||||
// The server serves static files under some known prefix. The kinds of files are:
|
||||
// - distsign.pub - bundle of PEM-encoded public signing keys
|
||||
// - distsign.pub.sig - signature of distsign.pub using one of the root keys
|
||||
// - $file - any distributable file
|
||||
// - $file.sig - signature of $file using any of the signing keys
|
||||
//
|
||||
// The root public keys are baked into the client software at compile time.
|
||||
// These keys are long-lived and prove the validity of current signing keys
|
||||
// from distsign.pub. To rotate root keys, a new client release must be
|
||||
// published, they are not rotated dynamically. There are multiple root keys in
|
||||
// different locations specifically to allow this rotation without using the
|
||||
// discarded root key for any new signatures.
|
||||
//
|
||||
// The signing public keys are fetched by the client dynamically before every
|
||||
// download and can be rotated more readily, assuming that most deployed
|
||||
// clients trust the root keys used to issue fresh signing keys.
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hdevalence/ed25519consensus"
|
||||
"golang.org/x/crypto/blake2s"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
const (
|
||||
pemTypeRootPrivate = "ROOT PRIVATE KEY"
|
||||
pemTypeRootPublic = "ROOT PUBLIC KEY"
|
||||
pemTypeSigningPrivate = "SIGNING PRIVATE KEY"
|
||||
pemTypeSigningPublic = "SIGNING PUBLIC KEY"
|
||||
|
||||
downloadSizeLimit = 1 << 29 // 512MB
|
||||
signingKeysSizeLimit = 1 << 20 // 1MB
|
||||
signatureSizeLimit = ed25519.SignatureSize
|
||||
)
|
||||
|
||||
// RootKey is a root key used to sign signing keys.
|
||||
type RootKey struct {
|
||||
k ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateRootKey generates a new root key pair and encodes it as PEM.
|
||||
func GenerateRootKey() (priv, pub []byte, err error) {
|
||||
pub, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeRootPrivate,
|
||||
Bytes: []byte(priv),
|
||||
}), pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeRootPublic,
|
||||
Bytes: []byte(pub),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ParseRootKey parses the PEM-encoded private root key. The key must be in the
|
||||
// same format as returned by GenerateRootKey.
|
||||
func ParseRootKey(privKey []byte) (*RootKey, error) {
|
||||
k, err := parsePrivateKey(privKey, pemTypeRootPrivate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse root key: %w", err)
|
||||
}
|
||||
return &RootKey{k: k}, nil
|
||||
}
|
||||
|
||||
// SignSigningKeys signs the bundle of public signing keys. The bundle must be
|
||||
// a sequence of PEM blocks joined with newlines.
|
||||
func (r *RootKey) SignSigningKeys(pubBundle []byte) ([]byte, error) {
|
||||
if _, err := ParseSigningKeyBundle(pubBundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ed25519.Sign(r.k, pubBundle), nil
|
||||
}
|
||||
|
||||
// SigningKey is a signing key used to sign packages.
|
||||
type SigningKey struct {
|
||||
k ed25519.PrivateKey
|
||||
}
|
||||
|
||||
// GenerateSigningKey generates a new signing key pair and encodes it as PEM.
|
||||
func GenerateSigningKey() (priv, pub []byte, err error) {
|
||||
pub, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeSigningPrivate,
|
||||
Bytes: []byte(priv),
|
||||
}), pem.EncodeToMemory(&pem.Block{
|
||||
Type: pemTypeSigningPublic,
|
||||
Bytes: []byte(pub),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ParseSigningKey parses the PEM-encoded private signing key. The key must be
|
||||
// in the same format as returned by GenerateSigningKey.
|
||||
func ParseSigningKey(privKey []byte) (*SigningKey, error) {
|
||||
k, err := parsePrivateKey(privKey, pemTypeSigningPrivate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse root key: %w", err)
|
||||
}
|
||||
return &SigningKey{k: k}, nil
|
||||
}
|
||||
|
||||
// SignPackageHash signs the hash and the length of a package. Use PackageHash
|
||||
// to compute the inputs.
|
||||
func (s *SigningKey) SignPackageHash(hash []byte, len int64) ([]byte, error) {
|
||||
if len <= 0 {
|
||||
return nil, fmt.Errorf("package length must be positive, got %d", len)
|
||||
}
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
|
||||
return ed25519.Sign(s.k, msg), nil
|
||||
}
|
||||
|
||||
// PackageHash is a hash.Hash that counts the number of bytes written. Use it
|
||||
// to get the hash and length inputs to SigningKey.SignPackageHash.
|
||||
type PackageHash struct {
|
||||
hash.Hash
|
||||
len int64
|
||||
}
|
||||
|
||||
// NewPackageHash returns an initialized PackageHash using BLAKE2s.
|
||||
func NewPackageHash() *PackageHash {
|
||||
h, err := blake2s.New256(nil)
|
||||
if err != nil {
|
||||
// Should never happen with a nil key passed to blake2s.
|
||||
panic(err)
|
||||
}
|
||||
return &PackageHash{Hash: h}
|
||||
}
|
||||
|
||||
func (ph *PackageHash) Write(b []byte) (int, error) {
|
||||
ph.len += int64(len(b))
|
||||
return ph.Hash.Write(b)
|
||||
}
|
||||
|
||||
// Reset the PackageHash to its initial state.
|
||||
func (ph *PackageHash) Reset() {
|
||||
ph.len = 0
|
||||
ph.Hash.Reset()
|
||||
}
|
||||
|
||||
// Len returns the total number of bytes written.
|
||||
func (ph *PackageHash) Len() int64 { return ph.len }
|
||||
|
||||
// Client downloads and validates files from a distribution server.
|
||||
type Client struct {
|
||||
logf logger.Logf
|
||||
roots []ed25519.PublicKey
|
||||
pkgsAddr *url.URL
|
||||
}
|
||||
|
||||
// NewClient returns a new client for distribution server located at pkgsAddr,
|
||||
// and uses embedded root keys from the roots/ subdirectory of this package.
|
||||
func NewClient(logf logger.Logf, pkgsAddr string) (*Client, error) {
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
u, err := url.Parse(pkgsAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pkgsAddr %q: %w", pkgsAddr, err)
|
||||
}
|
||||
return &Client{logf: logf, roots: roots(), pkgsAddr: u}, nil
|
||||
}
|
||||
|
||||
func (c *Client) url(path string) string {
|
||||
return c.pkgsAddr.JoinPath(path).String()
|
||||
}
|
||||
|
||||
// Download fetches a file at path srcPath from pkgsAddr passed in NewClient.
|
||||
// The file is downloaded to dstPath and its signature is validated using the
|
||||
// embedded root keys. Download returns an error if anything goes wrong with
|
||||
// the actual file download or with signature validation.
|
||||
func (c *Client) Download(ctx context.Context, srcPath, dstPath string) error {
|
||||
// Always fetch a fresh signing key.
|
||||
sigPub, err := c.signingKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcURL := c.url(srcPath)
|
||||
sigURL := srcURL + ".sig"
|
||||
|
||||
c.logf("Downloading %q", srcURL)
|
||||
dstPathUnverified := dstPath + ".unverified"
|
||||
hash, len, err := c.download(ctx, srcURL, dstPathUnverified, downloadSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logf("Downloading %q", sigURL)
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
// Best-effort clean up of downloaded package.
|
||||
os.Remove(dstPathUnverified)
|
||||
return err
|
||||
}
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
|
||||
if !VerifyAny(sigPub, msg, sig) {
|
||||
// Best-effort clean up of downloaded package.
|
||||
os.Remove(dstPathUnverified)
|
||||
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, srcURL)
|
||||
}
|
||||
c.logf("Signature OK")
|
||||
|
||||
if err := os.Rename(dstPathUnverified, dstPath); err != nil {
|
||||
return fmt.Errorf("failed to move %q to %q after signature validation", dstPathUnverified, dstPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLocalBinary fetches the latest signature associated with the binary
|
||||
// at srcURLPath and uses it to validate the file located on disk via
|
||||
// localFilePath. ValidateLocalBinary returns an error if anything goes wrong
|
||||
// with the signature download or with signature validation.
|
||||
func (c *Client) ValidateLocalBinary(srcURLPath, localFilePath string) error {
|
||||
// Always fetch a fresh signing key.
|
||||
sigPub, err := c.signingKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcURL := c.url(srcURLPath)
|
||||
sigURL := srcURL + ".sig"
|
||||
|
||||
localFile, err := os.Open(localFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer localFile.Close()
|
||||
|
||||
h := NewPackageHash()
|
||||
_, err = io.Copy(h, localFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, hashLen := h.Sum(nil), h.Len()
|
||||
|
||||
c.logf("Downloading %q", sigURL)
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := binary.LittleEndian.AppendUint64(hash, uint64(hashLen))
|
||||
if !VerifyAny(sigPub, msg, sig) {
|
||||
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, localFilePath)
|
||||
}
|
||||
c.logf("Signature OK")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signingKeys fetches current signing keys from the server and validates them
|
||||
// against the roots. Should be called before validation of any downloaded file
|
||||
// to get the fresh keys.
|
||||
func (c *Client) signingKeys() ([]ed25519.PublicKey, error) {
|
||||
keyURL := c.url("distsign.pub")
|
||||
sigURL := keyURL + ".sig"
|
||||
raw, err := fetch(keyURL, signingKeysSizeLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := fetch(sigURL, signatureSizeLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !VerifyAny(c.roots, raw, sig) {
|
||||
return nil, fmt.Errorf("signature %q for key %q does not validate with any known root key; either you are under attack, or running a very old version of Tailscale with outdated root keys", sigURL, keyURL)
|
||||
}
|
||||
|
||||
keys, err := ParseSigningKeyBundle(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse signing key bundle from %q: %w", keyURL, err)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// fetch reads the response body from url into memory, up to limit bytes.
|
||||
func fetch(url string, limit int64) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
}
|
||||
|
||||
// download writes the response body of url into a local file at dst, up to
|
||||
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
|
||||
func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{Transport: tr}
|
||||
|
||||
quickCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
headReq := must.Get(http.NewRequestWithContext(quickCtx, httpm.HEAD, url, nil))
|
||||
|
||||
res, err := hc.Do(headReq)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("HEAD %q: %v", url, res.Status)
|
||||
}
|
||||
if res.ContentLength <= 0 {
|
||||
return nil, 0, fmt.Errorf("HEAD %q: unexpected Content-Length %v", url, res.ContentLength)
|
||||
}
|
||||
c.logf("Download size: %v", res.ContentLength)
|
||||
|
||||
dlReq := must.Get(http.NewRequestWithContext(ctx, httpm.GET, url, nil))
|
||||
dlRes, err := hc.Do(dlReq)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer dlRes.Body.Close()
|
||||
// TODO(bradfitz): resume from existing partial file on disk
|
||||
if dlRes.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("GET %q: %v", url, dlRes.Status)
|
||||
}
|
||||
|
||||
of, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer of.Close()
|
||||
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
|
||||
h := NewPackageHash()
|
||||
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
|
||||
if err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
if n != res.ContentLength {
|
||||
return nil, n, fmt.Errorf("GET %q: downloaded %v, want %v", url, n, res.ContentLength)
|
||||
}
|
||||
if err := dlRes.Body.Close(); err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
if err := of.Close(); err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
pw.print()
|
||||
|
||||
return h.Sum(nil), h.Len(), nil
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
done int64
|
||||
total int64
|
||||
lastPrint time.Time
|
||||
logf logger.Logf
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
||||
pw.done += int64(len(p))
|
||||
if time.Since(pw.lastPrint) > 2*time.Second {
|
||||
pw.print()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (pw *progressWriter) print() {
|
||||
pw.lastPrint = time.Now()
|
||||
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
|
||||
}
|
||||
|
||||
func parsePrivateKey(data []byte, typeTag string) (ed25519.PrivateKey, error) {
|
||||
b, rest := pem.Decode(data)
|
||||
if b == nil {
|
||||
return nil, errors.New("failed to decode PEM data")
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
return nil, errors.New("trailing PEM data")
|
||||
}
|
||||
if b.Type != typeTag {
|
||||
return nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
|
||||
}
|
||||
if len(b.Bytes) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("private key has incorrect length for an Ed25519 private key")
|
||||
}
|
||||
return ed25519.PrivateKey(b.Bytes), nil
|
||||
}
|
||||
|
||||
// ParseSigningKeyBundle parses the bundle of PEM-encoded public signing keys.
|
||||
func ParseSigningKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
|
||||
return parsePublicKeyBundle(bundle, pemTypeSigningPublic)
|
||||
}
|
||||
|
||||
// ParseRootKeyBundle parses the bundle of PEM-encoded public root keys.
|
||||
func ParseRootKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
|
||||
return parsePublicKeyBundle(bundle, pemTypeRootPublic)
|
||||
}
|
||||
|
||||
func parsePublicKeyBundle(bundle []byte, typeTag string) ([]ed25519.PublicKey, error) {
|
||||
var keys []ed25519.PublicKey
|
||||
for len(bundle) > 0 {
|
||||
pub, rest, err := parsePublicKey(bundle, typeTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, pub)
|
||||
bundle = rest
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New("no signing keys found in the bundle")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func parseSinglePublicKey(data []byte, typeTag string) (ed25519.PublicKey, error) {
|
||||
pub, rest, err := parsePublicKey(data, typeTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
return nil, errors.New("trailing PEM data")
|
||||
}
|
||||
return pub, err
|
||||
}
|
||||
|
||||
func parsePublicKey(data []byte, typeTag string) (pub ed25519.PublicKey, rest []byte, retErr error) {
|
||||
b, rest := pem.Decode(data)
|
||||
if b == nil {
|
||||
return nil, nil, errors.New("failed to decode PEM data")
|
||||
}
|
||||
if b.Type != typeTag {
|
||||
return nil, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
|
||||
}
|
||||
if len(b.Bytes) != ed25519.PublicKeySize {
|
||||
return nil, nil, errors.New("public key has incorrect length for an Ed25519 public key")
|
||||
}
|
||||
return ed25519.PublicKey(b.Bytes), rest, nil
|
||||
}
|
||||
|
||||
// VerifyAny verifies whether sig is valid for msg using any of the keys.
|
||||
// VerifyAny will panic if any of the keys have the wrong size for Ed25519.
|
||||
func VerifyAny(keys []ed25519.PublicKey, msg, sig []byte) bool {
|
||||
for _, k := range keys {
|
||||
if ed25519consensus.Verify(k, msg, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/blake2s"
|
||||
)
|
||||
|
||||
func TestDownload(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
before func(*testing.T)
|
||||
src string
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "missing file",
|
||||
before: func(*testing.T) {},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "success",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
want: []byte("world"),
|
||||
},
|
||||
{
|
||||
desc: "no signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", []byte("potato"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with untrusted key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with root key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signing key signature",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("distsign.pub.sig", []byte("potato"))
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
srv.reset()
|
||||
tt.before(t)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), tt.src)
|
||||
t.Cleanup(func() {
|
||||
os.Remove(dst)
|
||||
})
|
||||
err := c.Download(context.Background(), tt.src, dst)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("Download(%q) succeeded, expected an error", tt.src)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(tt.want, got) {
|
||||
t.Errorf("Download(%q): got %q, want %q", tt.src, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalBinary(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
before func(*testing.T)
|
||||
src string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "missing file",
|
||||
before: func(*testing.T) {},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "success",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
},
|
||||
{
|
||||
desc: "contents changed",
|
||||
before: func(*testing.T) {
|
||||
srv.addSigned("hello", []byte("new world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "no signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signature",
|
||||
before: func(*testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", []byte("potato"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with untrusted key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", newSigningKeyPair(t).sign([]byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "signed with root key",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("hello", []byte("world"))
|
||||
srv.add("hello.sig", ed25519.Sign(srv.roots[0].k, []byte("world")))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "bad signing key signature",
|
||||
before: func(t *testing.T) {
|
||||
srv.add("distsign.pub.sig", []byte("potato"))
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
},
|
||||
src: "hello",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
srv.reset()
|
||||
|
||||
// First just do a successful Download.
|
||||
want := []byte("world")
|
||||
srv.addSigned("hello", want)
|
||||
dst := filepath.Join(t.TempDir(), tt.src)
|
||||
err := c.Download(context.Background(), tt.src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from Download(%q): %v", tt.src, err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(want, got) {
|
||||
t.Errorf("Download(%q): got %q, want %q", tt.src, got, want)
|
||||
}
|
||||
|
||||
// Now we reset srv with the test case and validate against the local dst.
|
||||
srv.reset()
|
||||
tt.before(t)
|
||||
|
||||
err = c.ValidateLocalBinary(tt.src, dst)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error from ValidateLocalBinary(%q): %v", tt.src, err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatalf("ValidateLocalBinary(%q) succeeded, expected an error", tt.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateRoot(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c1 := srv.client(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed on a fresh server: %v", err)
|
||||
}
|
||||
|
||||
// Remove first root and replace it with a new key.
|
||||
srv.roots = append(srv.roots[1:], newRootKeyPair(t))
|
||||
|
||||
// Old client can still download files because it still trusts the old
|
||||
// root key.
|
||||
if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after root rotation on old client: %v", err)
|
||||
}
|
||||
// New client should fail download because current signing key is signed by
|
||||
// the revoked root that new client doesn't trust.
|
||||
c2 := srv.client(t)
|
||||
if err := c2.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err == nil {
|
||||
t.Fatalf("Download succeeded on new client, but signing key is signed with revoked root key")
|
||||
}
|
||||
// Re-sign signing key with another valid root that client still trusts.
|
||||
srv.resignSigningKeys()
|
||||
// Both old and new clients should now be able to download.
|
||||
//
|
||||
// Note: we don't need to re-sign the "hello" file because signing key
|
||||
// didn't change (only signing key's signature).
|
||||
if err := c1.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after root rotation on old client with re-signed signing key: %v", err)
|
||||
}
|
||||
if err := c2.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after root rotation on new client with re-signed signing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateSigning(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
c := srv.client(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.addSigned("hello", []byte("world"))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed on a fresh server: %v", err)
|
||||
}
|
||||
|
||||
// Replace signing key but don't publish it yet.
|
||||
srv.sign = append(srv.sign, newSigningKeyPair(t))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after new signing key added but before publishing it: %v", err)
|
||||
}
|
||||
|
||||
// Publish new signing key bundle with both keys.
|
||||
srv.resignSigningKeys()
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after new signing key was published: %v", err)
|
||||
}
|
||||
|
||||
// Re-sign the "hello" file with new signing key.
|
||||
srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after re-signing with new signing key: %v", err)
|
||||
}
|
||||
|
||||
// Drop the old signing key.
|
||||
srv.sign = srv.sign[1:]
|
||||
srv.resignSigningKeys()
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after removing old signing key: %v", err)
|
||||
}
|
||||
|
||||
// Add another key and re-sign the file with it *before* publishing.
|
||||
srv.sign = append(srv.sign, newSigningKeyPair(t))
|
||||
srv.add("hello.sig", srv.sign[1].sign([]byte("world")))
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err == nil {
|
||||
t.Fatalf("Download succeeded when signed with a not-yet-published signing key")
|
||||
}
|
||||
// Fix this by publishing the new key.
|
||||
srv.resignSigningKeys()
|
||||
if err := c.Download(ctx, "hello", filepath.Join(t.TempDir(), "hello")); err != nil {
|
||||
t.Fatalf("Download failed after publishing new signing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRootKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
generate func() ([]byte, []byte, error)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "valid",
|
||||
generate: GenerateRootKey,
|
||||
},
|
||||
{
|
||||
desc: "signing",
|
||||
generate: GenerateSigningKey,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "nil",
|
||||
generate: func() ([]byte, []byte, error) { return nil, nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid PEM tag",
|
||||
generate: func() ([]byte, []byte, error) {
|
||||
priv, pub, err := GenerateRootKey()
|
||||
priv = bytes.Replace(priv, []byte("ROOT "), nil, -1)
|
||||
return priv, pub, err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "not PEM",
|
||||
generate: func() ([]byte, []byte, error) { return []byte("s3cr3t"), nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
priv, _, err := tt.generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ParseRootKey(priv)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if r == nil {
|
||||
t.Errorf("got nil error and nil RootKey")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSigningKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
generate func() ([]byte, []byte, error)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "valid",
|
||||
generate: GenerateSigningKey,
|
||||
},
|
||||
{
|
||||
desc: "root",
|
||||
generate: GenerateRootKey,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "nil",
|
||||
generate: func() ([]byte, []byte, error) { return nil, nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid PEM tag",
|
||||
generate: func() ([]byte, []byte, error) {
|
||||
priv, pub, err := GenerateSigningKey()
|
||||
priv = bytes.Replace(priv, []byte("SIGNING "), nil, -1)
|
||||
return priv, pub, err
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "not PEM",
|
||||
generate: func() ([]byte, []byte, error) { return []byte("s3cr3t"), nil, nil },
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
priv, _, err := tt.generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ParseSigningKey(priv)
|
||||
if err != nil {
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if r == nil {
|
||||
t.Errorf("got nil error and nil SigningKey")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testServer struct {
|
||||
roots []rootKeyPair
|
||||
sign []signingKeyPair
|
||||
files map[string][]byte
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
var roots []rootKeyPair
|
||||
for i := 0; i < 3; i++ {
|
||||
roots = append(roots, newRootKeyPair(t))
|
||||
}
|
||||
|
||||
ts := &testServer{
|
||||
roots: roots,
|
||||
sign: []signingKeyPair{newSigningKeyPair(t)},
|
||||
}
|
||||
ts.reset()
|
||||
ts.srv = httptest.NewServer(ts)
|
||||
t.Cleanup(ts.srv.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func (s *testServer) client(t *testing.T) *Client {
|
||||
roots := make([]ed25519.PublicKey, 0, len(s.roots))
|
||||
for _, r := range s.roots {
|
||||
pub, err := parseSinglePublicKey(r.pubRaw, pemTypeRootPublic)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePublicKey: %v", err)
|
||||
}
|
||||
roots = append(roots, pub)
|
||||
}
|
||||
u, err := url.Parse(s.srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Client{
|
||||
logf: t.Logf,
|
||||
roots: roots,
|
||||
pkgsAddr: u,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
data, ok := s.files[path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *testServer) addSigned(name string, data []byte) {
|
||||
s.files[name] = data
|
||||
s.files[name+".sig"] = s.sign[0].sign(data)
|
||||
}
|
||||
|
||||
func (s *testServer) add(name string, data []byte) {
|
||||
s.files[name] = data
|
||||
}
|
||||
|
||||
func (s *testServer) reset() {
|
||||
s.files = make(map[string][]byte)
|
||||
s.resignSigningKeys()
|
||||
}
|
||||
|
||||
func (s *testServer) resignSigningKeys() {
|
||||
var pubs [][]byte
|
||||
for _, k := range s.sign {
|
||||
pubs = append(pubs, k.pubRaw)
|
||||
}
|
||||
bundle := bytes.Join(pubs, []byte("\n"))
|
||||
sig := s.roots[0].sign(bundle)
|
||||
s.files["distsign.pub"] = bundle
|
||||
s.files["distsign.pub.sig"] = sig
|
||||
}
|
||||
|
||||
type rootKeyPair struct {
|
||||
*RootKey
|
||||
keyPair
|
||||
}
|
||||
|
||||
func newRootKeyPair(t *testing.T) rootKeyPair {
|
||||
privRaw, pubRaw, err := GenerateRootKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRootKey: %v", err)
|
||||
}
|
||||
kp := keyPair{
|
||||
privRaw: privRaw,
|
||||
pubRaw: pubRaw,
|
||||
}
|
||||
priv, err := parsePrivateKey(kp.privRaw, pemTypeRootPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey: %v", err)
|
||||
}
|
||||
return rootKeyPair{
|
||||
RootKey: &RootKey{k: priv},
|
||||
keyPair: kp,
|
||||
}
|
||||
}
|
||||
|
||||
func (s rootKeyPair) sign(bundle []byte) []byte {
|
||||
sig, err := s.SignSigningKeys(bundle)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
type signingKeyPair struct {
|
||||
*SigningKey
|
||||
keyPair
|
||||
}
|
||||
|
||||
func newSigningKeyPair(t *testing.T) signingKeyPair {
|
||||
privRaw, pubRaw, err := GenerateSigningKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSigningKey: %v", err)
|
||||
}
|
||||
kp := keyPair{
|
||||
privRaw: privRaw,
|
||||
pubRaw: pubRaw,
|
||||
}
|
||||
priv, err := parsePrivateKey(kp.privRaw, pemTypeSigningPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePrivateKey: %v", err)
|
||||
}
|
||||
return signingKeyPair{
|
||||
SigningKey: &SigningKey{k: priv},
|
||||
keyPair: kp,
|
||||
}
|
||||
}
|
||||
|
||||
func (s signingKeyPair) sign(blob []byte) []byte {
|
||||
hash := blake2s.Sum256(blob)
|
||||
sig, err := s.SignPackageHash(hash[:], int64(len(blob)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
type keyPair struct {
|
||||
privRaw []byte
|
||||
pubRaw []byte
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed roots
|
||||
var rootsFS embed.FS
|
||||
|
||||
var roots = sync.OnceValue(func() []ed25519.PublicKey {
|
||||
roots, err := parseRoots()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return roots
|
||||
})
|
||||
|
||||
func parseRoots() ([]ed25519.PublicKey, error) {
|
||||
files, err := rootsFS.ReadDir("roots")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []ed25519.PublicKey
|
||||
for _, f := range files {
|
||||
if !f.Type().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(f.Name()) != ".pem" {
|
||||
continue
|
||||
}
|
||||
raw, err := rootsFS.ReadFile(path.Join("roots", f.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := parseSinglePublicKey(raw, pemTypeRootPublic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing root key %q: %w", f.Name(), err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New("no embedded root keys, please check clientupdate/distsign/roots/")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
@@ -1,3 +0,0 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseRoots(t *testing.T) {
|
||||
roots, err := parseRoots()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
t.Error("parseRoots returned no root keys")
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/dbus"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
c, err := dbus.NewWithContext(ctx)
|
||||
if err != nil {
|
||||
// Likely not a systemd-managed distro.
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.ReloadContext(ctx); err != nil {
|
||||
return fmt.Errorf("failed to reload tailsacled.service: %w", err)
|
||||
}
|
||||
ch := make(chan string, 1)
|
||||
if _, err := c.RestartUnitContext(ctx, "tailscaled.service", "replace", ch); err != nil {
|
||||
return fmt.Errorf("failed to restart tailsacled.service: %w", err)
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res != "done" {
|
||||
return fmt.Errorf("systemd service restart failed with result %q", res)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user