Compare commits

..

1 Commits

Author SHA1 Message Date
Will Norris
1b35416b37 ipn/ipnlocal: allow running webclient on mobile 2024-01-10 09:47:31 -08:00
841 changed files with 14101 additions and 75086 deletions

View File

@@ -47,12 +47,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
# Install a more recent Go that understands modern go.mod content.
- name: Install Go
uses: actions/setup-go@v4
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2

64
.github/workflows/go-licenses.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: go-licenses
on:
# run action when a change lands in the main branch which updates go.mod or
# our license template file. Also allow manual triggering.
push:
branches:
- main
paths:
- go.mod
- .github/licenses.tmpl
- .github/workflows/go-licenses.yml
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
update-licenses:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version-file: go.mod
- name: Install go-licenses
run: |
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
- name: Run go-licenses
env:
# include all build tags to include platform-specific dependencies
GOFLAGS: "-tags=android,cgo,darwin,freebsd,ios,js,linux,openbsd,wasm,windows"
run: |
[ -d licenses ] || mkdir licenses
go-licenses report tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled > licenses/tailscale.md --template .github/licenses.tmpl
- name: Get access token
uses: tibdex/github-app-token@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: License Updater <noreply+license-updater@tailscale.com>
committer: License Updater <noreply+license-updater@tailscale.com>
branch: licenses/cli
commit-message: "licenses: update tailscale{,d} licenses"
title: "licenses: update tailscale{,d} licenses"
body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true
delete-branch: true
team-reviewers: opensource-license-reviewers

View File

@@ -34,7 +34,7 @@ jobs:
# Note: this is the 'v3' tag as of 2023-08-14
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.56
version: v1.54.2
# Show only new issues if it's a pull request.
only-new-issues: true

View File

@@ -32,6 +32,7 @@ jobs:
- "ubuntu:18.04"
- "ubuntu:20.04"
- "ubuntu:22.04"
- "ubuntu:22.10"
- "ubuntu:23.04"
- "elementary/docker:stable"
- "elementary/docker:unstable"
@@ -90,10 +91,7 @@ jobs:
|| contains(matrix.image, 'parrotsec')
|| contains(matrix.image, 'kalilinux')
- name: checkout
# We cannot use v4, as it requires a newer glibc version than some of the
# tested images provide. See
# https://github.com/actions/checkout/issues/1487
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: run installer
run: scripts/installer.sh
# Package installation can fail in docker because systemd is not running

View File

@@ -2,8 +2,8 @@ name: "Kubernetes manifests"
on:
pull_request:
paths:
- 'cmd/k8s-operator/**'
- 'k8s-operator/**'
- './cmd/k8s-operator/'
- './k8s-operator/'
- '.github/workflows/kubemanifests.yaml'
# Cancel workflow run if there is a newer push to the same PR for which it is

View File

@@ -1,23 +0,0 @@
# Run the ssh integration tests with `make sshintegrationtest`.
# These tests can also be running locally.
name: "ssh-integrationtest"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
paths:
- "ssh/**"
- "tempfork/gliderlabs/ssh/**"
- ".github/workflows/ssh-integrationtest"
jobs:
ssh-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run SSH integration tests
run: |
make sshintegrationtest

View File

@@ -183,19 +183,6 @@ jobs:
# the equals signs cause great confusion.
run: go test ./... -bench . -benchtime 1x -run "^$"
privileged:
runs-on: ubuntu-22.04
container:
image: golang:latest
options: --privileged
steps:
- name: checkout
uses: actions/checkout@v4
- name: chown
run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests
run: ./tool/go test ./util/linuxfw ./derp/xdp
vm:
runs-on: ["self-hosted", "linux", "vm"]
# VM tests run with some privileges, don't let them run on 3p PRs.
@@ -206,9 +193,9 @@ jobs:
- name: Run VM tests
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
HOME: "/var/lib/ghrunner/home"
HOME: "/tmp"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
race-build:
runs-on: ubuntu-22.04
@@ -254,6 +241,9 @@ jobs:
goarch: amd64
- goos: openbsd
goarch: amd64
# Plan9
- goos: plan9
goarch: amd64
runs-on: ubuntu-22.04
steps:
@@ -302,47 +292,6 @@ jobs:
GOOS: ios
GOARCH: arm64
crossmin: # cross-compile for platforms where we only check cmd/tailscale{,d}
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
# Plan9
- goos: plan9
goarch: amd64
# AIX
- goos: aix
goarch: ppc64
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 core
run: ./tool/go build ./cmd/tailscale ./cmd/tailscaled
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
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,
@@ -356,7 +305,7 @@ jobs:
# some Android breakages early.
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
- name: build some
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/netmon ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
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
@@ -480,7 +429,7 @@ jobs:
uses: actions/checkout@v4
- name: check that 'go generate' is clean
run: |
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
./tool/go generate $pkgs
echo
echo

1
.gitignore vendored
View File

@@ -9,7 +9,6 @@
cmd/tailscale/tailscale
cmd/tailscaled/tailscaled
ssh/tailssh/testcontainers/tailscaled
# Test binary, built with `go test -c`
*.test

View File

@@ -31,7 +31,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.22-alpine AS build-env
FROM golang:1.21-alpine AS build-env
WORKDIR /go/src/tailscale

View File

@@ -1,10 +1,8 @@
IMAGE_REPO ?= tailscale/tailscale
SYNO_ARCH ?= "x86_64"
SYNO_ARCH ?= "amd64"
SYNO_DSM ?= "7"
TAGS ?= "latest"
PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
vet: ## Run go vet
./tool/go vet ./...
@@ -90,7 +88,7 @@ publishdevimage: ## Build and publish tailscale image to location specified by $
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
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)
@@ -98,24 +96,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
.PHONY: sshintegrationtest
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"

View File

@@ -37,7 +37,7 @@ not open source.
## Building
We always require the latest Go release, currently Go 1.22. (While we build
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.)

View File

@@ -1 +1 @@
1.69.0
1.57.0

1833
api.md

File diff suppressed because it is too large Load Diff

View File

@@ -10,85 +10,24 @@
package appc
import (
"context"
"net/netip"
"slices"
"strings"
"sync"
"time"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/execqueue"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
)
// rateLogger responds to calls to update by adding a count for the current period and
// calling the callback if any previous period has finished since update was last called
type rateLogger struct {
interval time.Duration
start time.Time
periodStart time.Time
periodCount int64
now func() time.Time
callback func(int64, time.Time, int64)
}
func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
millisSince := now.Sub(rl.start).Milliseconds() % rl.interval.Milliseconds()
return now.Add(-(time.Duration(millisSince)) * time.Millisecond)
}
func (rl *rateLogger) update(numRoutes int64) {
now := rl.now()
periodEnd := rl.periodStart.Add(rl.interval)
if periodEnd.Before(now) {
if rl.periodCount != 0 {
rl.callback(rl.periodCount, rl.periodStart, numRoutes)
}
rl.periodCount = 0
rl.periodStart = rl.currentIntervalStart(now)
}
rl.periodCount++
}
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time, int64)) *rateLogger {
nowTime := now()
return &rateLogger{
callback: callback,
now: now,
interval: interval,
start: nowTime,
periodStart: nowTime,
}
}
// RouteAdvertiser is an interface that allows the AppConnector to advertise
// newly discovered routes that need to be served through the AppConnector.
type RouteAdvertiser interface {
// AdvertiseRoute adds one or more route advertisements skipping any that
// are already advertised.
AdvertiseRoute(...netip.Prefix) error
// UnadvertiseRoute removes any matching route advertisements.
UnadvertiseRoute(...netip.Prefix) error
}
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
// so that we can know, even after a restart, which routes came from ACLs and which were
// learned from domains.
type RouteInfo struct {
// Control is the routes from the 'routes' section of an app connector acl.
Control []netip.Prefix `json:",omitempty"`
// Domains are the routes discovered by observing DNS lookups for configured domains.
Domains map[string][]netip.Addr `json:",omitempty"`
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
// its result is added to Domains.
Wildcards []string `json:",omitempty"`
// 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
@@ -104,114 +43,29 @@ type AppConnector struct {
logf logger.Logf
routeAdvertiser RouteAdvertiser
// storeRoutesFunc will be called to persist routes if it is not nil.
storeRoutesFunc func(*RouteInfo) error
// mu guards the fields that follow
mu sync.Mutex
// domains is a map of lower case domain names with no trailing dot, to an
// ordered list of resolved IP addresses.
// domains is a map of lower case domain names with no trailing dot, to a
// list of resolved IP addresses.
domains map[string][]netip.Addr
// controlRoutes is the list of routes that were last supplied by control.
controlRoutes []netip.Prefix
// wildcards is the list of domain strings that match subdomains.
wildcards []string
// queue provides ordering for update operations
queue execqueue.ExecQueue
writeRateMinute *rateLogger
writeRateDay *rateLogger
}
// NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
ac := &AppConnector{
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
return &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
storeRoutesFunc: storeRoutesFunc,
}
if routeInfo != nil {
ac.domains = routeInfo.Domains
ac.wildcards = routeInfo.Wildcards
ac.controlRoutes = routeInfo.Control
}
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
})
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
})
return ac
}
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
// and is storing its discovered routes persistently.
func (e *AppConnector) ShouldStoreRoutes() bool {
return e.storeRoutesFunc != nil
}
// storeRoutesLocked takes the current state of the AppConnector and persists it
func (e *AppConnector) storeRoutesLocked() error {
if !e.ShouldStoreRoutes() {
return nil
}
// log write rate and write size
numRoutes := int64(len(e.controlRoutes))
for _, rs := range e.domains {
numRoutes += int64(len(rs))
}
e.writeRateMinute.update(numRoutes)
e.writeRateDay.update(numRoutes)
return e.storeRoutesFunc(&RouteInfo{
Control: e.controlRoutes,
Domains: e.domains,
Wildcards: e.wildcards,
})
}
// ClearRoutes removes all route state from the AppConnector.
func (e *AppConnector) ClearRoutes() error {
e.mu.Lock()
defer e.mu.Unlock()
e.controlRoutes = nil
e.domains = nil
e.wildcards = nil
return e.storeRoutesLocked()
}
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
// given the new domains and routes.
func (e *AppConnector) UpdateDomainsAndRoutes(domains []string, routes []netip.Prefix) {
e.queue.Add(func() {
// Add the new routes first.
e.updateRoutes(routes)
e.updateDomains(domains)
})
}
// UpdateDomains asynchronously replaces the current set of configured domains
// with the supplied set of domains. Domains must not contain a trailing dot,
// and should be lower case. If the domain contains a leading '*' label it
// matches all subdomains of a domain.
// 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. If the domain contains a leading '*' label it matches all
// subdomains of a domain.
func (e *AppConnector) UpdateDomains(domains []string) {
e.queue.Add(func() {
e.updateDomains(domains)
})
}
// Wait waits for the currently scheduled asynchronous configuration changes to
// complete.
func (e *AppConnector) Wait(ctx context.Context) {
e.queue.Wait(ctx)
}
func (e *AppConnector) updateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -236,80 +90,13 @@ func (e *AppConnector) updateDomains(domains []string) {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(d, wc) {
e.domains[d] = addrs
delete(oldDomains, d)
break
}
}
}
// Everything left in oldDomains is a domain we're no longer tracking
// and if we are storing route info we can unadvertise the routes
if e.ShouldStoreRoutes() {
toRemove := []netip.Prefix{}
for _, addrs := range oldDomains {
for _, a := range addrs {
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
}
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", xmaps.Keys(oldDomains), toRemove, err)
}
}
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
}
// updateRoutes merges the supplied routes into the currently configured routes. The routes supplied
// by control for UpdateRoutes are supplemental to the routes discovered by DNS resolution, but are
// also more often whole ranges. UpdateRoutes will remove any single address routes that are now
// covered by new ranges.
func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
e.mu.Lock()
defer e.mu.Unlock()
// If there was no change since the last update, no work to do.
if slices.Equal(e.controlRoutes, routes) {
return
}
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
return
}
var toRemove []netip.Prefix
// If we're storing routes and know e.controlRoutes is a good
// representation of what should be in AdvertisedRoutes we can stop
// advertising routes that used to be in e.controlRoutes but are not
// in routes.
if e.ShouldStoreRoutes() {
toRemove = routesWithout(e.controlRoutes, routes)
}
nextRoute:
for _, r := range routes {
for _, addr := range e.domains {
for _, a := range addr {
if r.Contains(a) && netip.PrefixFrom(a, a.BitLen()) != r {
pfx := netip.PrefixFrom(a, a.BitLen())
toRemove = append(toRemove, pfx)
continue nextRoute
}
}
}
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
e.controlRoutes = routes
if err := e.storeRoutesLocked(); err != nil {
e.logf("failed to store route info: %v", err)
}
}
// Domains returns the currently configured domain list.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
@@ -345,16 +132,6 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
return
}
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
// a CNAME chain back to the original query for flattening. The keys are
// CNAME record targets, and the value is the name the record answers, so
// for www.example.com CNAME example.com, the map would contain
// ["example.com"] = "www.example.com".
var cnameChain map[string]string
// addressRecords is a list of address records found in the response.
var addressRecords map[string][]netip.Addr
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
@@ -370,186 +147,73 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
}
continue
}
switch h.Type {
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
default:
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
domain := h.Name.String()
if len(domain) == 0 {
continue
return
}
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
e.logf("[v2] observed DNS response for %s", domain)
if h.Type == dnsmessage.TypeCNAME {
res, err := p.CNAMEResource()
if err != nil {
e.mu.Lock()
addrs, ok := e.domains[domain]
// match wildcard domains
if !ok {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(domain, wc) {
e.domains[domain] = nil
ok = true
break
}
}
}
e.mu.Unlock()
if !ok {
if err := p.SkipAnswer(); err != nil {
return
}
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
if len(cname) == 0 {
continue
}
mak.Set(&cnameChain, cname, domain)
continue
}
var addr netip.Addr
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
}
addr := netip.AddrFrom4(r.A)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
addr = netip.AddrFrom4(r.A)
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
}
addr := netip.AddrFrom16(r.AAAA)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
addr = netip.AddrFrom16(r.AAAA)
default:
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
}
e.mu.Lock()
defer e.mu.Unlock()
for domain, addrs := range addressRecords {
domain, isRouted := e.findRoutedDomainLocked(domain, cnameChain)
// domain and none of the CNAMEs in the chain are routed
if !isRouted {
if slices.Contains(addrs, addr) {
continue
}
// advertise each address we have learned for the routed domain, that
// was not already known.
var toAdvertise []netip.Prefix
for _, addr := range addrs {
if !e.isAddrKnownLocked(domain, addr) {
toAdvertise = append(toAdvertise, netip.PrefixFrom(addr, addr.BitLen()))
}
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
e.logf("failed to advertise route for %s: %v: %v", domain, addr, err)
continue
}
e.logf("[v2] advertised route for %v: %v", domain, addr)
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
// starting from the given domain that resolved to an address, find it, or any
// of the domains in the CNAME chain toward resolving it, that are routed
// domains, returning the routed domain name and a bool indicating whether a
// routed domain was found.
// e.mu must be held.
func (e *AppConnector) findRoutedDomainLocked(domain string, cnameChain map[string]string) (string, bool) {
var isRouted bool
for {
_, isRouted = e.domains[domain]
if isRouted {
break
}
// match wildcard domains
for _, wc := range e.wildcards {
if dnsname.HasSuffix(domain, wc) {
e.domains[domain] = nil
isRouted = true
break
}
}
next, ok := cnameChain[domain]
if !ok {
break
}
domain = next
}
return domain, isRouted
}
// isAddrKnownLocked returns true if the address is known to be associated with
// the given domain. Known domain tables are updated for covered routes to speed
// up future matches.
// e.mu must be held.
func (e *AppConnector) isAddrKnownLocked(domain string, addr netip.Addr) bool {
if e.hasDomainAddrLocked(domain, addr) {
return true
}
for _, route := range e.controlRoutes {
if route.Contains(addr) {
// record the new address associated with the domain for faster matching in subsequent
// requests and for diagnostic records.
e.addDomainAddrLocked(domain, addr)
return true
}
}
return false
}
// scheduleAdvertisement schedules an advertisement of the given address
// associated with the given domain.
func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Prefix) {
e.queue.Add(func() {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes for %s: %v: %v", domain, routes, err)
return
}
e.mu.Lock()
defer e.mu.Unlock()
for _, route := range routes {
if !route.IsSingleIP() {
continue
}
addr := route.Addr()
if !e.hasDomainAddrLocked(domain, addr) {
e.addDomainAddrLocked(domain, addr)
e.logf("[v2] advertised route for %v: %v", domain, addr)
}
}
if err := e.storeRoutesLocked(); err != nil {
e.logf("failed to store route info: %v", err)
}
})
}
// hasDomainAddrLocked returns true if the address has been observed in a
// resolution of domain.
func (e *AppConnector) hasDomainAddrLocked(domain string, addr netip.Addr) bool {
_, ok := slices.BinarySearchFunc(e.domains[domain], addr, compareAddr)
return ok
}
// addDomainAddrLocked adds the address to the list of addresses resolved for
// domain and ensures the list remains sorted. Does not attempt to deduplicate.
func (e *AppConnector) addDomainAddrLocked(domain string, addr netip.Addr) {
e.domains[domain] = append(e.domains[domain], addr)
slices.SortFunc(e.domains[domain], compareAddr)
}
func compareAddr(l, r netip.Addr) int {
return l.Compare(r)
}
// routesWithout returns a without b where a and b
// are unsorted slices of netip.Prefix
func routesWithout(a, b []netip.Prefix) []netip.Prefix {
m := make(map[netip.Prefix]bool, len(b))
for _, p := range b {
m[p] = true
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
}
return slicesx.Filter(make([]netip.Prefix, 0, len(a)), a, func(p netip.Prefix) bool {
return !m[p]
})
}

View File

@@ -4,253 +4,110 @@
package appc
import (
"context"
"net/netip"
"reflect"
"slices"
"testing"
"time"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc/appctest"
"tailscale.com/tstest"
"tailscale.com/util/mak"
"tailscale.com/util/must"
)
func fakeStoreRoutes(*RouteInfo) error { return nil }
func TestUpdateDomains(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil)
}
a.UpdateDomains([]string{"example.com"})
a.Wait(ctx)
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
a.Wait(ctx)
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
a.Wait(ctx)
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
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)
}
}
func TestUpdateRoutes(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"*.example.com"})
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
// This route should be collapsed into the range
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
a.Wait(ctx)
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
}
// This route should not be collapsed or removed
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
a.Wait(ctx)
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
a.updateRoutes(routes)
slices.SortFunc(rc.Routes(), prefixCompare)
rc.SetRoutes(slices.Compact(rc.Routes()))
slices.SortFunc(routes, prefixCompare)
// Ensure that the non-matching /32 is preserved, even though it's in the domains table.
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
}
// Ensure that the contained /32 is removed, replaced by the /24.
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
}
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
a.updateRoutes(routes)
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
t.Fatalf("got %v, want %v", rc.Routes(), routes)
}
// 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 TestDomainRoutes(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(context.Background())
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
want := map[string][]netip.Addr{
"example.com": {netip.MustParseAddr("192.0.0.8")},
}
want := map[string][]netip.Addr{
"example.com": {netip.MustParseAddr("192.0.0.8")},
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
}
func TestObserveDNSResponse(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
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)
}
// 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")}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
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)
}
// a CNAME record chain should result in a route being added if the chain
// matches a routed domain.
a.updateDomains([]string{"www.example.com", "example.com"})
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
// a CNAME record chain should result in a route being added if the chain
// even if only found in the middle of the chain
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
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)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
a.Wait(ctx)
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
}
// don't advertise addresses that are already in a control provided route
pfx := netip.MustParsePrefix("192.0.2.0/24")
a.updateRoutes([]netip.Prefix{pfx})
wantRoutes = append(wantRoutes, pfx)
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
a.Wait(ctx)
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
}
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
}
// 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)
}
}
func TestWildcardDomains(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
a.updateDomains([]string{"*.example.com"})
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
a.Wait(ctx)
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
t.Errorf("routes: got %v; want %v", got, want)
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.UpdateDomains([]string{"*.example.com"})
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
if got, want := rc.routes, []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
t.Errorf("routes: got %v; want %v", got, want)
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.updateDomains([]string{"*.example.com", "example.com"})
if _, ok := a.domains["foo.example.com"]; !ok {
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.UpdateDomains([]string{"*.example.com", "example.com"})
if _, ok := a.domains["foo.example.com"]; !ok {
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.updateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.UpdateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
}
@@ -291,281 +148,15 @@ func dnsResponse(domain, address string) []byte {
return must.Get(b.Finish())
}
func dnsCNAMEResponse(address string, domains ...string) []byte {
addr := netip.MustParseAddr(address)
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
if len(domains) >= 2 {
for i, domain := range domains[:len(domains)-1] {
b.CNAMEResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.CNAMEResource{
CNAME: dnsmessage.MustNewName(domains[i+1]),
},
)
}
}
domain := domains[len(domains)-1]
switch addr.BitLen() {
case 32:
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: addr.As4(),
},
)
case 128:
b.AAAAResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AAAAResource{
AAAA: addr.As16(),
},
)
default:
panic("invalid address length")
}
return must.Get(b.Finish())
// routeCollector is a test helper that collects the list of routes advertised
type routeCollector struct {
routes []netip.Prefix
}
func prefixEqual(a, b netip.Prefix) bool {
return a == b
}
func prefixCompare(a, b netip.Prefix) int {
if a.Addr().Compare(b.Addr()) == 0 {
return a.Bits() - b.Bits()
}
return a.Addr().Compare(b.Addr())
}
func prefixes(in ...string) []netip.Prefix {
toRet := make([]netip.Prefix, len(in))
for i, s := range in {
toRet[i] = netip.MustParsePrefix(s)
}
return toRet
}
func TestUpdateRouteRouteRemoval(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
if !slices.Equal(routes, rc.Routes()) {
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
}
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
}
}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
// nothing has yet been advertised
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32"))
a.Wait(ctx)
// the routes passed to UpdateDomainsAndRoutes have been advertised
assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{})
// one route the same, one different
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32"))
a.Wait(ctx)
// old behavior: routes are not removed, resulting routes are both old and new
// (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication
// the real one does)
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32")
wantRemovedRoutes := []netip.Prefix{}
if shouldStore {
// new behavior: routes are removed, resulting routes are new only
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32")
wantRemovedRoutes = prefixes("1.2.3.2/32")
}
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
}
}
func TestUpdateDomainRouteRemoval(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
if !slices.Equal(routes, rc.Routes()) {
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
}
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
}
}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3"))
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4"))
a.Wait(ctx)
// observing dns responses causes routes to be advertised
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// old behavior, routes are not removed
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
wantRemovedRoutes := []netip.Prefix{}
if shouldStore {
// new behavior, routes are removed for b.example.com
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
}
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
}
}
func TestUpdateWildcardRouteRemoval(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
if !slices.Equal(routes, rc.Routes()) {
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
}
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
}
}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3"))
a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4"))
a.Wait(ctx)
// observing dns responses causes routes to be advertised
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// old behavior, routes are not removed
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
wantRemovedRoutes := []netip.Prefix{}
if shouldStore {
// new behavior, routes are removed for *.b.example.com
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
}
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
}
}
func TestRoutesWithout(t *testing.T) {
assert := func(msg string, got, want []netip.Prefix) {
if !slices.Equal(want, got) {
t.Errorf("%s: want %v, got %v", msg, want, got)
}
}
assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{})
assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{})
assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32"))
assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32"))
assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{})
assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32"))
}
func TestRateLogger(t *testing.T) {
clock := tstest.Clock{}
wasCalled := false
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) {
if count != 3 {
t.Fatalf("count for prev period: got %d, want 3", count)
}
wasCalled = true
})
for i := 0; i < 3; i++ {
clock.Advance(1 * time.Millisecond)
rl.update(0)
if wasCalled {
t.Fatalf("wasCalled: got true, want false")
}
}
clock.Advance(1 * time.Second)
rl.update(0)
if !wasCalled {
t.Fatalf("wasCalled: got false, want true")
}
wasCalled = false
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) {
if count != 3 {
t.Fatalf("count for prev period: got %d, want 3", count)
}
wasCalled = true
})
for i := 0; i < 3; i++ {
clock.Advance(1 * time.Minute)
rl.update(0)
if wasCalled {
t.Fatalf("wasCalled: got true, want false")
}
}
clock.Advance(1 * time.Hour)
rl.update(0)
if !wasCalled {
t.Fatalf("wasCalled: got false, want true")
}
// routeCollector implements RouteAdvertiser
var _ RouteAdvertiser = (*routeCollector)(nil)
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package appctest
import (
"net/netip"
"slices"
)
// RouteCollector is a test helper that collects the list of routes advertised
type RouteCollector struct {
routes []netip.Prefix
removedRoutes []netip.Prefix
}
func (rc *RouteCollector) AdvertiseRoute(pfx ...netip.Prefix) error {
rc.routes = append(rc.routes, pfx...)
return nil
}
func (rc *RouteCollector) UnadvertiseRoute(toRemove ...netip.Prefix) error {
routes := rc.routes
rc.routes = rc.routes[:0]
for _, r := range routes {
if !slices.Contains(toRemove, r) {
rc.routes = append(rc.routes, r)
} else {
rc.removedRoutes = append(rc.removedRoutes, r)
}
}
return nil
}
// RemovedRoutes returns the list of routes that were removed.
func (rc *RouteCollector) RemovedRoutes() []netip.Prefix {
return rc.removedRoutes
}
// Routes returns the ordered list of routes that were added, including
// possible duplicates.
func (rc *RouteCollector) Routes() []netip.Prefix {
return rc.routes
}
func (rc *RouteCollector) SetRoutes(routes []netip.Prefix) error {
rc.routes = routes
return nil
}

View File

@@ -37,7 +37,7 @@ while [ "$#" -gt 1 ]; do
--extra-small)
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube"
;;
--box)
shift

View File

@@ -20,9 +20,9 @@
set -eu
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
export PATH="$PWD"/tool:"$PATH"
export PATH=$PWD/tool:$PATH
eval "$(./build_dist.sh shellvars)"
eval $(./build_dist.sh shellvars)
DEFAULT_TARGET="client"
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
@@ -32,7 +32,6 @@ PUSH="${PUSH:-false}"
TARGET="${TARGET:-${DEFAULT_TARGET}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
PLATFORM="${PLATFORM:-}" # default to all platforms
case "$TARGET" in
client)
@@ -49,10 +48,8 @@ case "$TARGET" in
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--gotags="ts_kube" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/containerboot
;;
operator)
@@ -68,27 +65,10 @@ case "$TARGET" in
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/operator
;;
k8s-nameserver)
DEFAULT_REPOS="tailscale/k8s-nameserver"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \
--gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-nameserver" \
--ldflags=" \
-X tailscale.com/version.longStamp=${VERSION_LONG} \
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/k8s-nameserver
;;
*)
echo "unknown target: $TARGET"
exit 1
;;
esac
esac

View File

@@ -270,14 +270,6 @@ type UserRuleMatch struct {
Users []string `json:"users"`
Ports []string `json:"ports"`
LineNumber int `json:"lineNumber"`
// Postures is a list of posture policies that are
// associated with this match. The rules can be looked
// up in the ACLPreviewResponse parent struct.
// The source of the list is from srcPosture on
// an ACL or Grant rule:
// https://tailscale.com/kb/1288/device-posture#posture-conditions
Postures []string `json:"postures"`
}
// ACLPreviewResponse is the response type of previewACLPostRequest
@@ -285,12 +277,6 @@ type ACLPreviewResponse struct {
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
Type string `json:"type"` // The request type: currently only "user" or "ipport".
PreviewFor string `json:"previewFor"` // A specific user or ipport.
// Postures is a map of postures and associated rules that apply
// to this preview.
// For more details about the posture mapping, see:
// https://tailscale.com/kb/1288/device-posture#postures
Postures map[string][]string `json:"postures,omitempty"`
}
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
@@ -298,12 +284,6 @@ type ACLPreview struct {
Matches []UserRuleMatch `json:"matches"`
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
// Postures is a map of postures and associated rules that apply
// to this preview.
// For more details about the posture mapping, see:
// https://tailscale.com/kb/1288/device-posture#postures
Postures map[string][]string `json:"postures,omitempty"`
}
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
@@ -361,9 +341,8 @@ func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (r
}
return &ACLPreview{
Matches: b.Matches,
User: b.PreviewFor,
Postures: b.Postures,
Matches: b.Matches,
User: b.PreviewFor,
}, nil
}
@@ -390,9 +369,8 @@ func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.
}
return &ACLPreview{
Matches: b.Matches,
IPPort: b.PreviewFor,
Postures: b.Postures,
Matches: b.Matches,
IPPort: b.PreviewFor,
}, nil
}
@@ -416,9 +394,8 @@ func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, use
}
return &ACLPreview{
Matches: b.Matches,
User: b.PreviewFor,
Postures: b.Postures,
Matches: b.Matches,
User: b.PreviewFor,
}, nil
}
@@ -442,9 +419,8 @@ func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, i
}
return &ACLPreview{
Matches: b.Matches,
IPPort: b.PreviewFor,
Postures: b.Postures,
Matches: b.Matches,
IPPort: b.PreviewFor,
}, nil
}

View File

@@ -49,11 +49,3 @@ type ReloadConfigResponse struct {
Reloaded bool // whether the config was reloaded
Err string // any error message
}
// ExitNodeSuggestionResponse is the response to a LocalAPI suggest-exit-node GET request.
// It returns the StableNodeID, name, and location of a suggested exit node for the client making the request.
type ExitNodeSuggestionResponse struct {
ID tailcfg.StableNodeID
Name string
Location tailcfg.LocationView `json:",omitempty"`
}

View File

@@ -10,7 +10,6 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
@@ -40,7 +39,6 @@ type Device struct {
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
Addresses []string `json:"addresses"`
DeviceID string `json:"id"`
NodeID string `json:"nodeId"`
User string `json:"user"`
Name string `json:"name"`
Hostname string `json:"hostname"`
@@ -215,9 +213,6 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
if err != nil {
return err
}
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {

View File

@@ -7,7 +7,6 @@ package tailscale
import (
"bytes"
"cmp"
"context"
"crypto/tls"
"encoding/json"
@@ -28,7 +27,6 @@ import (
"go4.org/mem"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/drive"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -39,6 +37,7 @@ import (
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
"tailscale.com/util/cmpx"
)
// defaultLocalClient is the default LocalClient when using the legacy
@@ -253,16 +252,11 @@ func (lc *LocalClient) sendWithHeaders(
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
return nil, nil, bestError(err, slurp)
}
return slurp, res.Header, nil
}
type httpStatusError struct {
error
HTTPStatus int
}
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
return lc.send(ctx, "GET", path, 200, nil)
}
@@ -283,50 +277,9 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// If not found, the error is ErrPeerNotFound.
//
// For connections proxied by tailscaled, this looks up the owner of the given
// address as TCP first, falling back to UDP; if you want to only check a
// specific address family, use WhoIsProto.
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
//
// If not found, the error is ErrPeerNotFound.
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is ErrPeerNotFound.
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
@@ -526,7 +479,7 @@ func (lc *LocalClient) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts)
opts = &DebugPortmapOpts{}
}
vals.Set("duration", cmp.Or(opts.Duration, 5*time.Second).String())
vals.Set("duration", cmpx.Or(opts.Duration, 5*time.Second).String())
vals.Set("type", opts.Type)
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
@@ -745,27 +698,6 @@ func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
return nil
}
// SetUDPGROForwarding enables UDP GRO forwarding for the main interface of this
// node. This can be done to improve performance of tailnet nodes acting as exit
// nodes or subnet routers.
// See https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
func (lc *LocalClient) SetUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/set-udp-gro-forwarding")
if err != nil {
return err
}
var jres struct {
Warning string
}
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from set-udp-gro-forwarding: %w", err)
}
if jres.Warning != "" {
return errors.New(jres.Warning)
}
return nil
}
// CheckPrefs validates the provided preferences, without making any changes.
//
// The CLI uses this before a Start call to fail fast if the preferences won't
@@ -845,17 +777,6 @@ func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
//
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
// UserDial connects to the host's port via Tailscale for the given network.
//
// The host may be a base DNS name (resolved from the netmap inside tailscaled),
// a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the
// net.Conn.
func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
@@ -868,11 +789,10 @@ func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port
return nil, err
}
req.Header = http.Header{
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
"Dial-Network": []string{network},
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
}
res, err := lc.DoLocalRequest(req)
if err != nil {
@@ -1497,66 +1417,6 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion,
return &cv, nil
}
// SetUseExitNode toggles the use of an exit node on or off.
// To turn it on, there must have been a previously used exit node.
// The most previously used one is reused.
// This is a convenience method for GUIs. To select an actual one, update the prefs.
func (lc *LocalClient) SetUseExitNode(ctx context.Context, on bool) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
return err
}
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
// the filesystem. This is used on platforms like Windows and MacOS to let
// Taildrive know to use the file server running in the GUI app.
func (lc *LocalClient) DriveSetServerAddr(ctx context.Context, addr string) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
return err
}
// DriveShareSet adds or updates the given share in the list of shares that
// Taildrive will serve to remote nodes. If a share with the same name already
// exists, the existing share is replaced/updated.
func (lc *LocalClient) DriveShareSet(ctx context.Context, share *drive.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
return err
}
// DriveShareRemove removes the share with the given name from the list of
// shares that Taildrive will serve to remote nodes.
func (lc *LocalClient) DriveShareRemove(ctx context.Context, name string) error {
_, err := lc.send(
ctx,
"DELETE",
"/localapi/v0/drive/shares",
http.StatusNoContent,
strings.NewReader(name))
return err
}
// DriveShareRename renames the share from old to new name.
func (lc *LocalClient) DriveShareRename(ctx context.Context, oldName, newName string) error {
_, err := lc.send(
ctx,
"POST",
"/localapi/v0/drive/shares",
http.StatusNoContent,
jsonBody([2]string{oldName, newName}))
return err
}
// DriveShareList returns the list of shares that drive is currently serving
// to remote nodes.
func (lc *LocalClient) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
if err != nil {
return nil, err
}
var shares []*drive.Share
err = json.Unmarshal(result, &shares)
return shares, err
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by LocalClient.WatchIPNBus.
//
@@ -1593,12 +1453,3 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
}
return n, nil
}
// SuggestExitNode requests an exit node suggestion and returns the exit node's details.
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node")
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err
}
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}

View File

@@ -5,16 +5,7 @@
package tailscale
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"tailscale.com/tstest/deptest"
"tailscale.com/types/key"
)
import "testing"
func TestGetServeConfigFromJSON(t *testing.T) {
sc, err := getServeConfigFromJSON([]byte("null"))
@@ -34,41 +25,3 @@ func TestGetServeConfigFromJSON(t *testing.T) {
t.Errorf("want non-nil TCP for object")
}
}
func TestWhoIsPeerNotFound(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer ts.Close()
lc := &LocalClient{
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var std net.Dialer
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
},
}
var k key.NodePublic
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
t.Fatal(err)
}
res, err := lc.WhoIsNodeKey(context.Background(), k)
if err != ErrPeerNotFound {
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
}
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
if err != ErrPeerNotFound {
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
}
}
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
// Make sure we don't again accidentally bring in a dependency on
// drive or its transitive dependencies
"testing": "do not use testing package in production code",
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}

View File

@@ -8,10 +8,8 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -223,7 +221,7 @@ func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) err
func (s *Server) newSessionID() (string, error) {
raw := make([]byte, 16)
for range 5 {
for i := 0; i < 5; i++ {
if _, err := rand.Read(raw); err != nil {
return "", err
}
@@ -234,106 +232,3 @@ func (s *Server) newSessionID() (string, error) {
}
return "", errors.New("too many collisions generating new session; please refresh page")
}
// peerCapabilities holds information about what a source
// peer is allowed to edit via the web UI.
//
// map value is true if the peer can edit the given feature.
// Only capFeatures included in validCaps will be included.
type peerCapabilities map[capFeature]bool
// canEdit is true if the peerCapabilities grant edit access
// to the given feature.
func (p peerCapabilities) canEdit(feature capFeature) bool {
if p == nil {
return false
}
if p[capFeatureAll] {
return true
}
return p[feature]
}
// isEmpty is true if p is either nil or has no capabilities
// with value true.
func (p peerCapabilities) isEmpty() bool {
if p == nil {
return true
}
for _, v := range p {
if v == true {
return false
}
}
return true
}
type capFeature string
const (
// The following values should not be edited.
// New caps can be added, but existing ones should not be changed,
// as these exact values are used by users in tailnet policy files.
//
// IMPORTANT: When adding a new cap, also update validCaps slice below.
capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
)
// validCaps contains the list of valid capabilities used in the web client.
// Any capabilities included in a peer's grants that do not fall into this
// list will be ignored.
var validCaps []capFeature = []capFeature{
capFeatureAll,
capFeatureSSH,
capFeatureSubnets,
capFeatureExitNodes,
capFeatureAccount,
}
type capRule struct {
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
}
// toPeerCapabilities parses out the web ui capabilities from the
// given whois response.
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
if whois == nil || status == nil {
return peerCapabilities{}, nil
}
if whois.Node.IsTagged() {
// We don't allow management *from* tagged nodes, so ignore caps.
// The web client auth flow relies on having a true user identity
// that can be verified through login.
return peerCapabilities{}, nil
}
if !status.Self.IsTagged() {
// User owned nodes are only ever manageable by the owner.
if status.Self.UserID != whois.UserProfile.ID {
return peerCapabilities{}, nil
} else {
return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
}
}
// For tagged nodes, we actually look at the granted capabilities.
caps := peerCapabilities{}
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
}
for _, c := range rules {
for _, f := range c.CanEdit {
cap := capFeature(strings.ToLower(f))
if slices.Contains(validCaps, cap) {
caps[cap] = true
}
}
}
return caps, nil
}

View File

@@ -6,7 +6,6 @@
"node": "18.16.1",
"yarn": "1.22.19"
},
"type": "module",
"private": true,
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
@@ -20,28 +19,23 @@
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/node": "^18.16.1",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.23.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-curly-quotes": "^1.0.4",
"jsdom": "^23.0.1",
"postcss": "^8.4.31",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^5.3.3",
"vite": "^5.1.7",
"vite-plugin-svgr": "^4.2.0",
"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": "^1.3.1"
},
"resolutions": {
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1"
"vitest": "^0.32.0"
},
"scripts": {
"build": "vite build",
@@ -56,11 +50,9 @@
"react-app"
],
"plugins": [
"curly-quotes",
"react-hooks"
],
"rules": {
"curly-quotes/no-straight-quotes": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
},

View File

@@ -4,8 +4,8 @@
import * as Primitive from "@radix-ui/react-popover"
import cx from "classnames"
import React, { useCallback } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Copy from "src/assets/icons/copy.svg?react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
import NiceIP from "src/components/nice-ip"
import useToaster from "src/hooks/toaster"
import Button from "src/ui/button"

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import LoginToggle from "src/components/login-toggle"
import DeviceDetailsView from "src/components/views/device-details-view"
import DisconnectedView from "src/components/views/disconnected-view"
@@ -11,8 +11,8 @@ import LoginView from "src/components/views/login-view"
import SSHView from "src/components/views/ssh-view"
import SubnetRouterView from "src/components/views/subnet-router-view"
import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse, canEdit } from "src/hooks/auth"
import { Feature, NodeData, featureDescription } from "src/types"
import useAuth, { AuthResponse } from "src/hooks/auth"
import { Feature, featureDescription, NodeData } from "src/types"
import Card from "src/ui/card"
import EmptyState from "src/ui/empty-state"
import LoadingDots from "src/ui/loading-dots"
@@ -56,19 +56,16 @@ function WebClient({
<Header node={node} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView node={node} auth={auth} />
<HomeView readonly={!auth.canManageNode} node={node} />
</Route>
<Route path="/details">
<DeviceDetailsView node={node} auth={auth} />
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
</Route>
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
<SubnetRouterView
readonly={!canEdit("subnets", auth)}
node={node}
/>
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
</FeatureRoute>
<FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
<SSHView readonly={!auth.canManageNode} node={node} />
</FeatureRoute>
{/* <Route path="/serve">Share local content</Route> */}
<FeatureRoute path="/update" feature="auto-update" node={node}>

View File

@@ -4,8 +4,8 @@
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAPI } from "src/api"
import Check from "src/assets/icons/check.svg?react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import { ReactComponent as Check } from "src/assets/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import useExitNodes, {
noExitNode,
runAsExitNode,
@@ -180,7 +180,7 @@ export default function ExitNodeSelector({
)}
{pending && (
<p className="text-white p-3">
Pending approval to run as exit node. This device wont be usable as
Pending approval to run as exit node. This device won't be usable as
an exit node until then.
</p>
)}

View File

@@ -2,17 +2,15 @@
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useMemo, useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Eye from "src/assets/icons/eye.svg?react"
import User from "src/assets/icons/user.svg?react"
import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth"
import { useTSWebConnected } from "src/hooks/ts-web-connected"
import React, { useCallback, useEffect, useState } from "react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
import { ReactComponent as User } from "src/assets/icons/user.svg"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"
import { assertNever, isHTTPS } from "src/utils/util"
export default function LoginToggle({
node,
@@ -24,29 +22,12 @@ export default function LoginToggle({
newSession: () => Promise<void>
}) {
const [open, setOpen] = useState<boolean>(false)
const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
auth.serverMode,
node.IPv4
)
return (
<Popover
className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
content={
auth.serverMode === "readonly" ? (
<ReadonlyModeContent auth={auth} />
) : auth.serverMode === "login" ? (
<LoginModeContent
auth={auth}
node={node}
tsWebConnected={tsWebConnected}
checkTSWebConnection={checkTSWebConnection}
/>
) : auth.serverMode === "manage" ? (
<ManageModeContent auth={auth} node={node} newSession={newSession} />
) : (
assertNever(auth.serverMode)
)
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
}
side="bottom"
align="end"
@@ -54,303 +35,201 @@ export default function LoginToggle({
onOpenChange={setOpen}
asChild
>
<div>
{auth.authorized ? (
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
) : (
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
)}
</div>
{!auth.canManageNode ? (
<button
className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
) : (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div>
)}
</Popover>
)
}
/**
* TriggerWhenManaging is displayed as the trigger for the login popover
* when the user has an active authorized managment session.
*/
function TriggerWhenManaging({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
</button>
</div>
)
}
/**
* TriggerWhenReading is displayed as the trigger for the login popover
* when the user is currently in read mode (doesn't have an authorized
* management session).
*/
function TriggerWhenReading({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<button
className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
)
}
/**
* PopoverContentHeader is the header for the login popover.
*/
function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
return (
<div className="text-black text-sm font-medium leading-tight mb-1">
{auth.authorized ? "Managing" : "Viewing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
)
}
/**
* PopoverContentFooter is the footer for the login popover.
*/
function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
return auth.viewerIdentity ? (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
) : null
}
/**
* ReadonlyModeContent is the body of the login popover when the web
* client is being run in "readonly" server mode.
*/
function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
return (
<>
<PopoverContentHeader auth={auth} />
<p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "}
<a
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<PopoverContentFooter auth={auth} />
</>
)
}
/**
* LoginModeContent is the body of the login popover when the web
* client is being run in "login" server mode.
*/
function LoginModeContent({
function LoginPopoverContent({
node,
auth,
tsWebConnected,
checkTSWebConnection,
}: {
node: NodeData
auth: AuthResponse
tsWebConnected: boolean
checkTSWebConnection: () => void
}) {
const https = isHTTPS()
// We can't run the ts web connection test when the webpage is loaded
// over HTTPS. So in this case, we default to presenting a login button
// with some helper text reminding the user to check their connection
// themselves.
const hasACLAccess = https || tsWebConnected
const hasEditCaps = useMemo(() => {
if (!auth.viewerIdentity) {
// If not connected to login client over tailscale, we won't know the viewer's
// identity. So we must assume they may be able to edit something and have the
// management client handle permissions once the user gets there.
return true
}
return hasAnyEditCapabilities(auth)
}, [auth])
const handleLogin = useCallback(() => {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// If we're inside an iframe, open management client in new window.
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
}, [node.IPv4])
return (
<div
onMouseEnter={
hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
}
>
<PopoverContentHeader auth={auth} />
{!hasACLAccess || !hasEditCaps ? (
<>
<p className="text-gray-500 text-xs">
{!hasEditCaps ? (
// ACLs allow access, but user isn't allowed to edit any features,
// restricted to readonly. No point in sending them over to the
// tailscaleIP:5252 address.
<>
You dont have permission to make changes to this device, but
you can view most of its details.
</>
) : !node.ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access to anyone.
<>
The current tailnet policy file does not allow connecting to
this device.
</>
) : (
// ACLs don't allow access to this user specifically.
<>
Cannot access this devices Tailscale IP. Make sure you are
connected to your tailnet, and that your policy file allows
access.
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-access"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</>
) : (
// User can connect to Tailcale IP; sign in when ready.
<>
<p className="text-gray-500 text-xs">
You can see most of this devices details. To make changes, you need
to sign in.
</p>
{https && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your policy
file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleLogin} />
</>
)}
<PopoverContentFooter auth={auth} />
</div>
)
}
/**
* ManageModeContent is the body of the login popover when the web
* client is being run in "manage" server mode.
*/
function ManageModeContent({
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => void
newSession: () => Promise<void>
}) {
const handleLogin = useCallback(() => {
if (window.self !== window.top) {
// If we're inside an iframe, start session in new window.
let url = new URL(window.location.href)
url.searchParams.set("check", "now")
window.open(url, "_blank")
} else {
newSession()
}
}, [newSession])
/**
* canConnectOverTS indicates whether the current viewer
* is able to hit the node's web client that's being served
* at http://${node.IP}:5252. If false, this means that the
* viewer must connect to the correct tailnet before being
* able to sign in.
*/
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity) {
setCanConnectOverTS(true) // already connected over ts
return
}
// Otherwise, test connection to the ts IP.
if (isRunningCheck) {
return // already checking
}
setIsRunningCheck(true)
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setCanConnectOverTS(true)
setIsRunningCheck(false)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
/**
* Checking connection for first time on page load.
*
* While not connected, we check again whenever the mouse
* enters the popover component, to pick up on the user
* leaving to turn on Tailscale then returning to the view.
* See `onMouseEnter` on the div below.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSConnection(), [])
const handleSignInClick = useCallback(() => {
if (auth.viewerIdentity && auth.serverMode === "manage") {
if (window.self !== window.top) {
// if we're inside an iframe, start session in new window
let url = new URL(window.location.href)
url.searchParams.set("check", "now")
window.open(url, "_blank")
} else {
newSession()
}
} else {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// if we're inside an iframe, open management client in new window
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
}
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
return (
<>
<PopoverContentHeader auth={auth} />
{!auth.authorized &&
(hasAnyPermissions ? (
// User is connected over Tailscale, but needs to complete check mode.
<>
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
<div className="text-black text-sm font-medium leading-tight mb-1">
{!auth.canManageNode ? "Viewing" : "Managing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
{!auth.canManageNode && (
<>
{!auth.viewerIdentity ? (
// User is not connected over Tailscale.
// These states are only possible on the login client.
<>
{!canConnectOverTS ? (
<>
<p className="text-gray-500 text-xs">
{!node.ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access.
<>
The current tailnet policy file does not allow
connecting to this device.
</>
) : (
// ACLs allow access, but user can't connect.
<>
Cannot access this device's Tailscale IP. Make sure you
are connected to your tailnet, and that your policy file
allows access.
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-connection"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</>
) : (
// User can connect to Tailcale IP; sign in when ready.
<>
<p className="text-gray-500 text-xs">
You can see most of this device's details. To make changes,
you need to sign in.
</p>
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
)}
</>
) : auth.authNeeded === AuthType.tailscale ? (
// User is connected over Tailscale, but needs to complete check mode.
<>
<p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</p>
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra step
helps us keep your device secure.
You dont have permission to make changes to this device, but you
can view most of its details.
</p>
<SignInButton auth={auth} onClick={handleLogin} />
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.{" "}
<a
href="https://tailscale.com/s/web-client-access"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
))}
<PopoverContentFooter auth={auth} />
</>
)}
</>
)}
{auth.viewerIdentity && (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
)}
</div>
)
}

View File

@@ -61,7 +61,7 @@ export function ChangelogText({ version }: { version?: string }) {
<a href="https://tailscale.com/changelog/" className="link">
release notes
</a>{" "}
to find out whats new!
to find out what's new!
</>
)
}

View File

@@ -8,7 +8,6 @@ import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components"
import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Card from "src/ui/card"
@@ -17,11 +16,11 @@ import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter"
export default function DeviceDetailsView({
readonly,
node,
auth,
}: {
readonly: boolean
node: NodeData
auth: AuthResponse
}) {
return (
<>
@@ -38,11 +37,11 @@ export default function DeviceDetailsView({
})}
/>
</div>
{canEdit("account", auth) && <DisconnectDialog />}
{!readonly && <DisconnectDialog />}
</div>
</Card>
{node.Features["auto-update"] &&
canEdit("account", auth) &&
!readonly &&
node.ClientVersion &&
!node.ClientVersion.RunningLatest && (
<UpdateAvailableNotification details={node.ClientVersion} />

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
/**
* DisconnectedView is rendered after node logout.

View File

@@ -4,22 +4,21 @@
import cx from "classnames"
import React, { useMemo } from "react"
import { apiFetch } from "src/api"
import ArrowRight from "src/assets/icons/arrow-right.svg?react"
import Machine from "src/assets/icons/machine.svg?react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import Card from "src/ui/card"
import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter"
export default function HomeView({
readonly,
node,
auth,
}: {
readonly: boolean
node: NodeData
auth: AuthResponse
}) {
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [
@@ -64,11 +63,7 @@ export default function HomeView({
</div>
{(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && (
<ExitNodeSelector
className="mb-5"
node={node}
disabled={!canEdit("exitnodes", auth)}
/>
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
)}
<Link
className="link font-medium"

View File

@@ -3,7 +3,7 @@
import React, { useState } from "react"
import { useAPI } from "src/api"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Collapsible from "src/ui/collapsible"
@@ -41,7 +41,7 @@ export default function LoginView({ data }: { data: NodeData }) {
<>
<div className="mb-6">
<p className="text-gray-700">
Your devices key has expired. Reauthenticate this device by
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"

View File

@@ -4,9 +4,9 @@
import cx from "classnames"
import React, { useCallback, useMemo, useState } from "react"
import { useAPI } from "src/api"
import CheckCircle from "src/assets/icons/check-circle.svg?react"
import Clock from "src/assets/icons/clock.svg?react"
import Plus from "src/assets/icons/plus.svg?react"
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
import * as Control from "src/components/control-components"
import { NodeData } from "src/types"
import Button from "src/ui/button"

View File

@@ -2,8 +2,8 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import CheckCircleIcon from "src/assets/icons/check-circle.svg?react"
import XCircleIcon from "src/assets/icons/x-circle.svg?react"
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
import { ChangelogText } from "src/components/update-available"
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
import { VersionInfo } from "src/types"
@@ -35,7 +35,7 @@ export function UpdatingView({
<Spinner size="sm" className="text-gray-400" />
<h1 className="text-2xl m-3">Update in progress</h1>
<p className="text-gray-400">
The update shouldnt take more than a couple of minutes. Once its
The update shouldn't take more than a couple of minutes. Once it's
completed, you will be asked to log in again.
</p>
</>

View File

@@ -4,50 +4,25 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
export type AuthResponse = {
serverMode: AuthServerMode
authorized: boolean
authNeeded?: AuthType
canManageNode: boolean
serverMode: "login" | "manage"
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
capabilities: { [key in PeerCapability]: boolean }
}
needsSynoAuth?: boolean
}
export type AuthServerMode = "login" | "readonly" | "manage"
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
/**
* canEdit reports whether the given auth response specifies that the viewer
* has the ability to edit the given capability.
*/
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
if (!auth.authorized || !auth.viewerIdentity) {
return false
}
if (auth.viewerIdentity.capabilities["*"] === true) {
return true // can edit all features
}
return auth.viewerIdentity.capabilities[cap] === true
}
/**
* hasAnyEditCapabilities reports whether the given auth response specifies
* that the viewer has at least one edit capability. If this is true, the
* user is able to go through the auth flow to authenticate a management
* session.
*/
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
}
/**
* useAuth reports and refreshes Tailscale auth status for the web client.
*/
// 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)
@@ -58,16 +33,18 @@ export default function useAuth() {
return apiFetch<AuthResponse>("/auth", "GET")
.then((d) => {
setData(d)
if (d.needsSynoAuth) {
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setRanSynoAuth(true)
setLoading(false)
})
} else {
setLoading(false)
switch (d.authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setRanSynoAuth(true)
setLoading(false)
})
break
default:
setLoading(false)
}
return d
})
@@ -95,13 +72,8 @@ export default function useAuth() {
useEffect(() => {
loadAuth().then((d) => {
if (!d) {
return
}
if (
!d.authorized &&
hasAnyEditCapabilities(d) &&
// Start auth flow immediately if browser has requested it.
!d?.canManageNode &&
new URLSearchParams(window.location.search).get("check") === "now"
) {
newSession()

View File

@@ -1,46 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { isHTTPS } from "src/utils/util"
import { AuthServerMode } from "./auth"
/**
* useTSWebConnected hook is used to check whether the browser is able to
* connect to the web client served at http://${nodeIPv4}:5252
*/
export function useTSWebConnected(mode: AuthServerMode, nodeIPv4: string) {
const [tsWebConnected, setTSWebConnected] = useState<boolean>(
mode === "manage" // browser already on the web client
)
const [isLoading, setIsLoading] = useState<boolean>(false)
const checkTSWebConnection = useCallback(() => {
if (mode === "manage") {
// Already connected to the web client.
setTSWebConnected(true)
return
}
if (isHTTPS()) {
// When page is loaded over HTTPS, the connectivity check will always
// fail with a mixed-content error. In this case don't bother doing
// the check.
return
}
if (isLoading) {
return // already checking
}
setIsLoading(true)
fetch(`http://${nodeIPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setTSWebConnected(true)
setIsLoading(false)
})
.catch(() => setIsLoading(false))
}, [isLoading, mode, nodeIPv4])
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSWebConnection(), []) // checking connection for first time on page load
return { tsWebConnected, checkTSWebConnection, isLoading }
}

View File

@@ -3,7 +3,7 @@
import * as Primitive from "@radix-ui/react-collapsible"
import React, { useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
type CollapsibleProps = {
trigger?: string

View File

@@ -4,7 +4,7 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import cx from "classnames"
import React, { Component, ComponentProps, FormEvent } from "react"
import X from "src/assets/icons/x.svg?react"
import { ReactComponent as X } from "src/assets/icons/x.svg"
import Button from "src/ui/button"
import PortalContainerContext from "src/ui/portal-container-context"
import { isObject } from "src/utils/util"

View File

@@ -3,7 +3,7 @@
import cx from "classnames"
import React, { forwardRef, InputHTMLAttributes } from "react"
import Search from "src/assets/icons/search.svg?react"
import { ReactComponent as Search } from "src/assets/icons/search.svg"
type Props = {
className?: string

View File

@@ -10,7 +10,7 @@ import React, {
useState,
} from "react"
import { createPortal } from "react-dom"
import X from "src/assets/icons/x.svg?react"
import { ReactComponent as X } from "src/assets/icons/x.svg"
import { noop } from "src/utils/util"
import { create } from "zustand"
import { shallow } from "zustand/shallow"

View File

@@ -49,10 +49,3 @@ export function isPromise<T = unknown>(val: unknown): val is Promise<T> {
}
return typeof val === "object" && "then" in val
}
/**
* isHTTPS reports whether the current page is loaded over HTTPS.
*/
export function isHTTPS() {
return window.location.protocol === "https:"
}

View File

@@ -1,7 +1,7 @@
import plugin from "tailwindcss/plugin"
import styles from "./styles.json"
const plugin = require("tailwindcss/plugin")
const styles = require("./styles.json")
const config = {
module.exports = {
theme: {
screens: {
sm: "420px",
@@ -96,22 +96,20 @@ const config = {
plugins: [
plugin(function ({ addVariant }) {
addVariant("state-open", [
"&[data-state=open”]",
"[data-state=open] &",
'&[data-state="open"]',
'[data-state="open"] &',
])
addVariant("state-closed", [
"&[data-state=closed”]",
"[data-state=closed] &",
'&[data-state="closed"]',
'[data-state="closed"] &',
])
addVariant("state-delayed-open", [
"&[data-state=delayed-open”]",
"[data-state=delayed-open] &",
'&[data-state="delayed-open"]',
'[data-state="delayed-open"] &',
])
addVariant("state-active", ["&[data-state=active”]"])
addVariant("state-inactive", ["&[data-state=inactive”]"])
addVariant("state-active", ['&[data-state="active"]'])
addVariant("state-inactive", ['&[data-state="inactive"]'])
}),
],
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
}
export default config

View File

@@ -5,7 +5,6 @@
"module": "ES2020",
"strict": true,
"sourceMap": true,
"skipLibCheck": true,
"isolatedModules": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,

View File

@@ -1,5 +1,6 @@
/// <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"
@@ -23,6 +24,11 @@ export default defineConfig({
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",

View File

@@ -95,14 +95,6 @@ const (
// In this mode, API calls are authenticated via platform auth.
LoginServerMode ServerMode = "login"
// ReadOnlyServerMode is identical to LoginServerMode,
// but does not present a login button to switch to manage mode,
// even if the management client is running and reachable.
//
// This is designed for platforms where the device is configured by other means,
// such as Home Assistant's declarative YAML configuration.
ReadOnlyServerMode ServerMode = "readonly"
// ManageServerMode serves a management client for editing tailscale
// settings of a node.
//
@@ -162,7 +154,7 @@ type ServerOpts struct {
// and not the lifespan of the web server.
func NewServer(opts ServerOpts) (s *Server, err error) {
switch opts.Mode {
case LoginServerMode, ReadOnlyServerMode, ManageServerMode:
case LoginServerMode, ManageServerMode:
// valid types
case "":
return nil, fmt.Errorf("must specify a Mode")
@@ -215,14 +207,10 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
// 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))
switch s.mode {
case LoginServerMode:
if s.mode == LoginServerMode {
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_login_client_initialization"
case ReadOnlyServerMode:
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_readonly_client_initialization"
case ManageServerMode:
} else {
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
metric = "web_client_initialization"
}
@@ -445,198 +433,27 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
}
}
type apiHandler[data any] struct {
s *Server
w http.ResponseWriter
r *http.Request
type authType string
// permissionCheck allows for defining whether a requesting peer's
// capabilities grant them access to make the given data update.
// If permissionCheck reports false, the request fails as unauthorized.
permissionCheck func(data data, peer peerCapabilities) bool
}
// newHandler constructs a new api handler which restricts the given request
// to the specified permission check. If the permission check fails for
// the peer associated with the request, an unauthorized error is returned
// to the client.
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
return &apiHandler[data]{
s: s,
w: w,
r: r,
permissionCheck: permissionCheck,
}
}
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
// for requests that are always allowed to complete regardless of a peer's
// capabilities.
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
// WhoIs when originally checking for a session from authorizeRequest.
// Would be nice if we could pipe those through to here so we don't end
// up having to re-call them to grab the peer capabilities.
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
if err != nil {
return nil, err
}
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
if err != nil {
return nil, err
}
peer, err := toPeerCapabilities(status, whois)
if err != nil {
return nil, err
}
return peer, nil
}
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
// handle runs the given handler if the source peer satisfies the
// constraints for running this request.
//
// handle is expected for use when `data` type is empty, or set to
// `noBodyData` in practice. For requests that expect JSON body data
// to be attached, use handleJSON instead.
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
var body data // not used
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
h(a.w, a.r)
}
// handleJSON manages decoding the request's body JSON and passing
// it on to the provided function if the source peer satisfies the
// constraints for running this request.
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
defer a.r.Body.Close()
var body data
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
if err := h(a.r.Context(), body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
a.w.WriteHeader(http.StatusOK)
}
// 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) {
if r.Method == httpm.PATCH {
// Enforce that PATCH requests are always application/json.
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
}
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetNodeData)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetExitNodes)
return
case path == "/routes" && r.Method == httpm.POST:
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
return false
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
return false
}
return true
}
newHandler[postRoutesRequest](s, w, r, peerAllowed).
handleJSON(s.servePostRoutes)
return
case path == "/device-details-click" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveDeviceDetailsClick)
return
case path == "/local/v0/logout" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return false
}
return true
}
newHandler[maskedPrefs](s, w, r, peerAllowed).
handleJSON(s.serveUpdatePrefs)
return
case path == "/local/v0/update/check" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/update/check" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
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 {
ServerMode ServerMode `json:"serverMode"`
Authorized bool `json:"authorized"` // has an authorized management session
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
CanManageNode bool `json:"canManageNode"`
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"`
ServerMode ServerMode `json:"serverMode"`
}
// viewerIdentity is the Tailscale identity of the source node
// connected to this web client.
type viewerIdentity struct {
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
Capabilities peerCapabilities `json:"capabilities"` // features peer is allowed to edit
LoginName string `json:"loginName"`
NodeName string `json:"nodeName"`
NodeIP string `json:"nodeIP"`
ProfilePicURL string `json:"profilePicUrl,omitempty"`
}
// serverAPIAuth handles requests to the /api/auth endpoint
@@ -645,20 +462,12 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
var resp authResponse
resp.ServerMode = s.mode
session, whois, status, sErr := s.getSession(r)
var caps peerCapabilities
if whois != nil {
var err error
caps, err = toPeerCapabilities(status, whois)
if err != nil {
http.Error(w, sErr.Error(), http.StatusInternalServerError)
return
}
resp.ViewerIdentity = &viewerIdentity{
LoginName: whois.UserProfile.LoginName,
NodeName: whois.Node.Name,
ProfilePicURL: whois.UserProfile.ProfilePicURL,
Capabilities: caps,
}
if addrs := whois.Node.Addresses; len(addrs) > 0 {
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
@@ -667,7 +476,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
// First verify platform auth.
// If platform auth is needed, this should happen first.
if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode {
if s.mode == LoginServerMode {
switch distro.Get() {
case distro.Synology:
authorized, err := authorizeSynology(r)
@@ -676,7 +485,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
return
}
if !authorized {
resp.NeedsSynoAuth = true
resp.AuthNeeded = synoAuth
writeJSON(w, resp)
return
}
@@ -692,17 +501,21 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
resp.Authorized = false // restricted to the readonly view
resp.AuthNeeded = ""
case sErr != nil && errors.Is(sErr, errNotOwner):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
resp.Authorized = false // restricted to the readonly view
resp.AuthNeeded = ""
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
resp.Authorized = false // restricted to the readonly view
resp.AuthNeeded = ""
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
resp.Authorized = false // restricted to the readonly view
resp.AuthNeeded = ""
case sErr != nil && !errors.Is(sErr, errNoSession):
// Any other error.
http.Error(w, sErr.Error(), http.StatusInternalServerError)
@@ -713,26 +526,16 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
} else {
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
}
// User has a valid session. They're now authorized to edit if they
// have any edit capabilities. In practice, they won't be sent through
// the auth flow if they don't have edit caps, but their ACL granted
// permissions may change at any time. The frontend views and backend
// endpoints are always restricted to their current capabilities in
// addition to a valid session.
//
// But, we also check the caps here for a better user experience on
// the frontend login toggle, which uses resp.Authorized to display
// "viewing" vs "managing" copy. If they don't have caps, we want to
// display "viewing" even if they have a valid session.
resp.Authorized = !caps.isEmpty()
resp.CanManageNode = true
resp.AuthNeeded = ""
default:
// whois being nil implies local as the request did not come over Tailscale
if whois == nil || (whois.Node.StableID == status.Self.ID) {
// whois being nil implies local as the request did not come over Tailscale.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
} else {
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
}
resp.Authorized = false // not yet authorized
resp.AuthNeeded = tailscaleAuth
}
writeJSON(w, resp)
@@ -796,6 +599,32 @@ func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request)
}
}
// 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" && r.Method == httpm.GET:
s.serveGetNodeData(w, r)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
s.serveGetExitNodes(w, r)
return
case path == "/routes" && r.Method == httpm.POST:
s.servePostRoutes(w, r)
return
case path == "/device-details-click" && r.Method == httpm.POST:
s.serveDeviceDetailsClick(w, r)
return
case strings.HasPrefix(path, "/local/"):
s.proxyRequestToLocalAPI(w, r)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
type nodeData struct {
ID tailcfg.StableNodeID
Status string
@@ -1032,23 +861,6 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, exitNodes)
}
// maskedPrefs is the subset of ipn.MaskedPrefs that are
// allowed to be editable via the web UI.
type maskedPrefs struct {
RunSSHSet bool
RunSSH bool
}
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
RunSSHSet: prefs.RunSSHSet,
Prefs: ipn.Prefs{
RunSSH: prefs.RunSSH,
},
})
return err
}
type postRoutesRequest struct {
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
SetRoutes bool // when set, AdvertiseRoutes value is applied
@@ -1057,10 +869,18 @@ type postRoutesRequest struct {
AdvertiseRoutes []string
}
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
prefs, err := s.lc.GetPrefs(ctx)
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var data postRoutesRequest
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prefs, err := s.lc.GetPrefs(r.Context())
if err != nil {
return err
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var currNonExitRoutes []string
var currAdvertisingExitNode bool
@@ -1083,7 +903,8 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
routesStr := strings.Join(data.AdvertiseRoutes, ",")
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
if err != nil {
return err
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasExitNodeRoute := func(all []netip.Prefix) bool {
@@ -1092,7 +913,8 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
}
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
return errors.New("cannot use and advertise exit node at same time")
http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest)
return
}
// Make prefs update.
@@ -1104,8 +926,12 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
AdvertiseRoutes: routes,
},
}
_, err = s.lc.EditPrefs(ctx, p)
return err
if _, err := s.lc.EditPrefs(r.Context(), p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// tailscaleUp starts the daemon with the provided options.
@@ -1150,15 +976,7 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails
if !isRunning {
ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
if opt.ControlURL != "" {
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ControlURL: opt.ControlURL,
},
ControlURLSet: true,
})
if err != nil {
s.logf("edit prefs: %v", err)
}
ipnOptions.UpdatePrefs = &ipn.Prefs{ControlURL: opt.ControlURL}
}
if err := s.lc.Start(ctx, ipnOptions); err != nil {
s.logf("start: %v", err)
@@ -1252,12 +1070,26 @@ func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request)
//
// 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 r.Method == httpm.PATCH {
// enforce that PATCH requests are always application/json
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
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)
@@ -1282,6 +1114,21 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
}
}
// 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.
var localapiAllowlist = []string{
"/v0/logout",
"/v0/prefs",
"/v0/update/check",
"/v0/update/install",
"/v0/update/progress",
"/v0/upload-client-metrics",
}
// 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.

View File

@@ -4,7 +4,6 @@
package web
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -87,172 +86,75 @@ func TestQnapAuthnURL(t *testing.T) {
// TestServeAPI tests the web client api's handling of
// 1. invalid endpoint errors
// 2. permissioning of api endpoints based on node capabilities
// 2. localapi proxy allowlist
func TestServeAPI(t *testing.T) {
selfTags := views.SliceOf([]string{"tag:server"})
self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags}
prefs := &ipn.Prefs{}
remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
remoteIPWithAllCapabilities := "100.100.100.101"
remoteIPWithNoCapabilities := "100.100.100.102"
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{
remoteIPWithAllCapabilities: {
Node: &tailcfg.Node{StableID: "node1"},
UserProfile: remoteUser,
CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}},
},
remoteIPWithNoCapabilities: {
Node: &tailcfg.Node{StableID: "node2"},
UserProfile: remoteUser,
},
},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs { return prefs },
nil,
)
// 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{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
}
type requestTest struct {
remoteIP string
wantResponse string
wantStatus int
}
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
tests := []struct {
reqPath string
name string
reqMethod string
reqPath string
reqContentType string
reqBody string
tests []requestTest
wantResp string
wantStatus int
}{{
reqPath: "/not-an-endpoint",
reqMethod: httpm.POST,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}},
name: "invalid_endpoint",
reqMethod: httpm.POST,
reqPath: "/not-an-endpoint",
wantResp: "invalid endpoint",
wantStatus: http.StatusNotFound,
}, {
reqPath: "/local/v0/not-an-endpoint",
reqMethod: httpm.POST,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "invalid endpoint",
wantStatus: http.StatusNotFound,
}},
name: "not_in_localapi_allowlist",
reqMethod: httpm.POST,
reqPath: "/local/v0/not-allowlisted",
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
wantStatus: http.StatusForbidden,
}, {
reqPath: "/local/v0/logout",
reqMethod: httpm.POST,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed", // requesting node has insufficient permissions
wantStatus: http.StatusUnauthorized,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "success", // requesting node has sufficient permissions
wantStatus: http.StatusOK,
}},
name: "in_localapi_allowlist",
reqMethod: httpm.POST,
reqPath: "/local/v0/logout",
wantResp: "success", // Successfully allowed to hit localapi.
wantStatus: http.StatusOK,
}, {
reqPath: "/exit-nodes",
reqMethod: httpm.GET,
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "null",
wantStatus: http.StatusOK, // allowed, no additional capabilities required
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "null",
wantStatus: http.StatusOK,
}},
}, {
reqPath: "/routes",
reqMethod: httpm.POST,
reqBody: "{\"setExitNode\":true}",
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed",
wantStatus: http.StatusUnauthorized,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantStatus: http.StatusOK,
}},
}, {
reqPath: "/local/v0/prefs",
name: "patch_bad_contenttype",
reqMethod: httpm.PATCH,
reqBody: "{\"runSSHSet\":true}",
reqContentType: "application/json",
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "not allowed",
wantStatus: http.StatusUnauthorized,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantStatus: http.StatusOK,
}},
}, {
reqPath: "/local/v0/prefs",
reqMethod: httpm.PATCH,
reqContentType: "multipart/form-data",
tests: []requestTest{{
remoteIP: remoteIPWithNoCapabilities,
wantResponse: "invalid request",
wantStatus: http.StatusBadRequest,
}, {
remoteIP: remoteIPWithAllCapabilities,
wantResponse: "invalid request",
wantStatus: http.StatusBadRequest,
}},
wantResp: "invalid request",
wantStatus: http.StatusBadRequest,
}}
for _, tt := range tests {
for _, req := range tt.tests {
t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) {
var reqBody io.Reader
if tt.reqBody != "" {
reqBody = bytes.NewBuffer([]byte(tt.reqBody))
}
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody)
r.RemoteAddr = req.remoteIP
if tt.reqContentType != "" {
r.Header.Add("Content-Type", tt.reqContentType)
}
w := httptest.NewRecorder()
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
if tt.reqContentType != "" {
r.Header.Add("Content-Type", tt.reqContentType)
}
w := httptest.NewRecorder()
s.serveAPI(w, r)
res := w.Result()
defer res.Body.Close()
if gotStatus := res.StatusCode; req.wantStatus != gotStatus {
t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
if req.wantResponse != gotResp {
t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp)
}
})
}
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)
}
})
}
}
@@ -548,7 +450,6 @@ func TestServeAuth(t *testing.T) {
NodeName: remoteNode.Node.Name,
NodeIP: remoteIP,
ProfilePicURL: user.ProfilePicURL,
Capabilities: peerCapabilities{capFeatureAll: true},
}
testControlURL := &defaultControlURL
@@ -622,7 +523,7 @@ func TestServeAuth(t *testing.T) {
name: "no-session",
path: "/api/auth",
wantStatus: http.StatusOK,
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantNewCookie: false,
wantSession: nil,
},
@@ -647,7 +548,7 @@ func TestServeAuth(t *testing.T) {
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -695,7 +596,7 @@ func TestServeAuth(t *testing.T) {
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
@@ -1196,217 +1097,6 @@ func TestRequireTailscaleIP(t *testing.T) {
}
}
func TestPeerCapabilities(t *testing.T) {
userOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{UserID: tailcfg.UserID(1)}}
tags := views.SliceOf[string]([]string{"tag:server"})
tagOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{Tags: &tags}}
// Testing web.toPeerCapabilities
toPeerCapsTests := []struct {
name string
status *ipnstate.Status
whois *apitype.WhoIsResponse
wantCaps peerCapabilities
}{
{
name: "empty-whois",
status: userOwnedStatus,
whois: nil,
wantCaps: peerCapabilities{},
},
{
name: "user-owned-node-non-owner-caps-ignored",
status: userOwnedStatus,
whois: &apitype.WhoIsResponse{
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)},
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
{
name: "user-owned-node-owner-caps-ignored",
status: userOwnedStatus,
whois: &apitype.WhoIsResponse{
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{capFeatureAll: true}, // should just have wildcard
},
{
name: "tag-owned-no-webui-caps",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
},
},
wantCaps: peerCapabilities{},
},
{
name: "tag-owned-one-webui-cap",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnets: true,
},
},
{
name: "tag-owned-multiple-webui-cap",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
"{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnets: true,
capFeatureExitNodes: true,
capFeatureAll: true,
},
},
{
name: "tag-owned-case-insensitive-caps",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"SSH\",\"sUBnets\"]}",
},
},
},
wantCaps: peerCapabilities{
capFeatureSSH: true,
capFeatureSubnets: true,
},
},
{
name: "tag-owned-random-canEdit-contents-get-dropped",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"unknown-feature\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
{
name: "tag-owned-no-canEdit-section",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canDoSomething\":[\"*\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
{
name: "tagged-source-caps-ignored",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
}
for _, tt := range toPeerCapsTests {
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
got, err := toPeerCapabilities(tt.status, tt.whois)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
t.Errorf("wrong caps; (-got+want):%v", diff)
}
})
}
// Testing web.peerCapabilities.canEdit
canEditTests := []struct {
name string
caps peerCapabilities
wantCanEdit map[capFeature]bool
}{
{
name: "empty-caps",
caps: nil,
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureSSH: false,
capFeatureSubnets: false,
capFeatureExitNodes: false,
capFeatureAccount: false,
},
},
{
name: "some-caps",
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: false,
capFeatureSSH: true,
capFeatureSubnets: false,
capFeatureExitNodes: false,
capFeatureAccount: true,
},
},
{
name: "wildcard-in-caps",
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{
capFeatureAll: true,
capFeatureSSH: true,
capFeatureSubnets: true,
capFeatureExitNodes: true,
capFeatureAccount: true,
},
},
}
for _, tt := range canEditTests {
t.Run("canEdit-"+tt.name, func(t *testing.T) {
for f, want := range tt.wantCanEdit {
if got := tt.caps.canEdit(f); got != want {
t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
}
}
})
}
}
var (
defaultControlURL = "https://controlplane.tailscale.com"
testAuthPath = "/a/12345"
@@ -1453,9 +1143,6 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
metricCapture(metricNames[0].Name)
writeJSON(w, struct{}{})
return
case "/localapi/v0/logout":
fmt.Fprintf(w, "success")
return
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}

File diff suppressed because it is too large Load Diff

View File

@@ -37,18 +37,11 @@ import (
)
const (
CurrentTrack = ""
StableTrack = "stable"
UnstableTrack = "unstable"
)
var CurrentTrack = func() string {
if version.IsUnstableBuild() {
return UnstableTrack
} else {
return StableTrack
}
}()
func versionToTrack(v string) (string, error) {
_, rest, ok := strings.Cut(v, ".")
if !ok {
@@ -113,7 +106,7 @@ func (args Arguments) validate() error {
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
}
switch args.Track {
case StableTrack, UnstableTrack, "":
case StableTrack, UnstableTrack, CurrentTrack:
// All valid values.
default:
return fmt.Errorf("unsupported track %q", args.Track)
@@ -126,17 +119,11 @@ type Updater struct {
// Update is a platform-specific method that updates the installation. May be
// nil (not all platforms support updates from within Tailscale).
Update func() error
// currentVersion is the short form of the current client version as
// returned by version.Short(), typically "x.y.z". Used for tests to
// override the actual current version.
currentVersion string
}
func NewUpdater(args Arguments) (*Updater, error) {
up := Updater{
Arguments: args,
currentVersion: version.Short(),
Arguments: args,
}
if up.Stdout == nil {
up.Stdout = os.Stdout
@@ -152,15 +139,18 @@ func NewUpdater(args Arguments) (*Updater, error) {
if args.ForAutoUpdate && !canAutoUpdate {
return nil, errors.ErrUnsupported
}
if up.Track == "" {
if up.Version != "" {
if up.Track == CurrentTrack {
switch {
case up.Version != "":
var err error
up.Track, err = versionToTrack(args.Version)
if err != nil {
return nil, err
}
} else {
up.Track = CurrentTrack
case version.IsUnstableBuild():
up.Track = UnstableTrack
default:
up.Track = StableTrack
}
}
if up.Arguments.PkgsAddr == "" {
@@ -177,10 +167,6 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
return up.updateWindows, true
case "linux":
switch distro.Get() {
case distro.NixOS:
// NixOS packages are immutable and managed with a system-wide
// configuration.
return up.updateNixos, false
case distro.Synology:
// Synology updates use our own pkgs.tailscale.com instead of the
// Synology Package Center. We should eventually get to a regular
@@ -269,16 +255,13 @@ func Update(args Arguments) error {
}
func (up *Updater) confirm(ver string) bool {
// Only check version when we're not switching tracks.
if up.Track == "" || up.Track == CurrentTrack {
switch c := cmpver.Compare(up.currentVersion, ver); {
case c == 0:
up.Logf("already running %v version %v; no update needed", up.Track, ver)
return false
case c > 0:
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, up.currentVersion, ver)
return false
}
switch cmpver.Compare(version.Short(), ver) {
case 0:
up.Logf("already running %v version %v; no update needed", up.Track, ver)
return false
case 1:
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, version.Short(), ver)
return false
}
if up.Confirm != nil {
return up.Confirm(ver)
@@ -449,7 +432,7 @@ func (up *Updater) updateDebLike() error {
return fmt.Errorf("apt-get update failed: %w; output:\n%s", err, out)
}
for range 2 {
for i := 0; i < 2; i++ {
out, err := exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver).CombinedOutput()
if err != nil {
if !bytes.Contains(out, []byte(`dpkg was interrupted`)) {
@@ -539,13 +522,6 @@ func (up *Updater) updateArchLike() error {
you can use "pacman --sync --refresh --sysupgrade" or "pacman -Syu" to upgrade the system, including Tailscale.`)
}
func (up *Updater) updateNixos() error {
// NixOS package updates are managed on a system level and not individually.
// Direct users to update their nix channel or nixpkgs flake input to
// receive the latest version.
return errors.New(`individual package updates are not supported on NixOS installations. Update your system channel or flake inputs to get the latest Tailscale version from nixpkgs.`)
}
const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"
// updateFedoraLike updates tailscale on any distros in the Fedora family,
@@ -664,9 +640,6 @@ func (up *Updater) updateAlpineLike() (err error) {
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
}
if !up.confirm(ver) {
if err := checkOutdatedAlpineRepo(up.Logf, ver, up.Track); err != nil {
up.Logf("failed to check whether Alpine release is outdated: %v", err)
}
return nil
}
@@ -681,7 +654,6 @@ func (up *Updater) updateAlpineLike() (err error) {
func parseAlpinePackageVersion(out []byte) (string, error) {
s := bufio.NewScanner(bytes.NewReader(out))
var maxVer string
for s.Scan() {
// The line should look like this:
// tailscale-1.44.2-r0 description:
@@ -693,48 +665,11 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
if len(parts) < 3 {
return "", fmt.Errorf("malformed info line: %q", line)
}
ver := parts[1]
if cmpver.Compare(ver, maxVer) > 0 {
maxVer = ver
}
}
if maxVer != "" {
return maxVer, nil
return parts[1], nil
}
return "", errors.New("tailscale version not found in output")
}
var apkRepoVersionRE = regexp.MustCompile(`v[0-9]+\.[0-9]+`)
func checkOutdatedAlpineRepo(logf logger.Logf, apkVer, track string) error {
latest, err := LatestTailscaleVersion(track)
if err != nil {
return err
}
if latest == apkVer {
// Actually on latest release.
return nil
}
f, err := os.Open("/etc/apk/repositories")
if err != nil {
return err
}
defer f.Close()
// Read the first repo line. Typically, there are multiple repos that all
// contain the same version in the path, like:
// https://dl-cdn.alpinelinux.org/alpine/v3.20/main
// https://dl-cdn.alpinelinux.org/alpine/v3.20/community
s := bufio.NewScanner(f)
if !s.Scan() {
return s.Err()
}
alpineVer := apkRepoVersionRE.FindString(s.Text())
if alpineVer != "" {
logf("The latest Tailscale release for Linux is %q, but your apk repository only provides %q.\nYour Alpine version is %q, you may need to upgrade the system to get the latest Tailscale version: https://wiki.alpinelinux.org/wiki/Upgrading_Alpine", latest, apkVer, alpineVer)
}
return nil
}
func (up *Updater) updateMacSys() error {
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
}
@@ -883,7 +818,7 @@ func (up *Updater) switchOutputToFile() (io.Closer, error) {
func (up *Updater) installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/norestart", "/qn")
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
@@ -893,7 +828,7 @@ func (up *Updater) installMSI(msi string) error {
break
}
up.Logf("Install attempt failed: %v", err)
uninstallVersion := up.currentVersion
uninstallVersion := version.Short()
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
}
@@ -1064,20 +999,6 @@ func (up *Updater) updateLinuxBinary() error {
return nil
}
func restartSystemdUnit(ctx context.Context) error {
if _, err := exec.LookPath("systemctl"); err != nil {
// Likely not a systemd-managed distro.
return errors.ErrUnsupported
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl daemon-reload failed: %w\noutput: %s", err, out)
}
if out, err := exec.Command("systemctl", "restart", "tailscaled.service").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl restart failed: %w\noutput: %s", err, out)
}
return nil
}
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
dlDir, err := os.UserCacheDir()
if err != nil {
@@ -1344,31 +1265,22 @@ func requestedTailscaleVersion(ver, track string) (string, error) {
// LatestTailscaleVersion returns the latest released version for the given
// track from pkgs.tailscale.com.
func LatestTailscaleVersion(track string) (string, error) {
if track == "" {
track = CurrentTrack
if track == CurrentTrack {
if version.IsUnstableBuild() {
track = UnstableTrack
} else {
track = StableTrack
}
}
latest, err := latestPackages(track)
if err != nil {
return "", err
}
ver := latest.Version
switch runtime.GOOS {
case "windows":
ver = latest.MSIsVersion
case "darwin":
ver = latest.MacZipsVersion
case "linux":
ver = latest.TarballsVersion
if distro.Get() == distro.Synology {
ver = latest.SPKsVersion
}
if latest.Version == "" {
return "", fmt.Errorf("no latest version found for %q track", track)
}
if ver == "" {
return "", fmt.Errorf("no latest version found for OS %q on %q track", runtime.GOOS, track)
}
return ver, nil
return latest.Version, nil
}
type trackPackages struct {

View File

@@ -251,29 +251,6 @@ tailscale installed size:
out: "",
wantErr: true,
},
{
desc: "multiple versions",
out: `
tailscale-1.54.1-r0 description:
The easiest, most secure way to use WireGuard and 2FA
tailscale-1.54.1-r0 webpage:
https://tailscale.com/
tailscale-1.54.1-r0 installed size:
34 MiB
tailscale-1.58.2-r0 description:
The easiest, most secure way to use WireGuard and 2FA
tailscale-1.58.2-r0 webpage:
https://tailscale.com/
tailscale-1.58.2-r0 installed size:
35 MiB
`,
want: "1.58.2",
},
}
for _, tt := range tests {
@@ -663,7 +640,7 @@ func genTarball(t *testing.T, path string, files map[string]string) {
func TestWriteFileOverwrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "test")
for i := range 2 {
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)
@@ -846,107 +823,3 @@ func TestParseUnraidPluginVersion(t *testing.T) {
})
}
}
func TestConfirm(t *testing.T) {
curTrack := CurrentTrack
defer func() { CurrentTrack = curTrack }()
tests := []struct {
desc string
fromTrack string
toTrack string
fromVer string
toVer string
confirm func(string) bool
want bool
}{
{
desc: "on latest stable",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.66.0",
want: false,
},
{
desc: "stable upgrade",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.68.0",
want: true,
},
{
desc: "unstable upgrade",
fromTrack: UnstableTrack,
toTrack: UnstableTrack,
fromVer: "1.67.1",
toVer: "1.67.2",
want: true,
},
{
desc: "from stable to unstable",
fromTrack: StableTrack,
toTrack: UnstableTrack,
fromVer: "1.66.0",
toVer: "1.67.1",
want: true,
},
{
desc: "from unstable to stable",
fromTrack: UnstableTrack,
toTrack: StableTrack,
fromVer: "1.67.1",
toVer: "1.66.0",
want: true,
},
{
desc: "confirm callback rejects",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.66.1",
confirm: func(string) bool {
return false
},
want: false,
},
{
desc: "confirm callback allows",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.0",
toVer: "1.66.1",
confirm: func(string) bool {
return true
},
want: true,
},
{
desc: "downgrade",
fromTrack: StableTrack,
toTrack: StableTrack,
fromVer: "1.66.1",
toVer: "1.66.0",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
CurrentTrack = tt.fromTrack
up := Updater{
currentVersion: tt.fromVer,
Arguments: Arguments{
Track: tt.toTrack,
Confirm: tt.confirm,
Logf: t.Logf,
},
}
if got := up.confirm(tt.toVer); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -445,7 +445,7 @@ type testServer struct {
func newTestServer(t *testing.T) *testServer {
var roots []rootKeyPair
for range 3 {
for i := 0; i < 3; i++ {
roots = append(roots, newRootKeyPair(t))
}

View File

@@ -0,0 +1,37 @@
// 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
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package clientupdate
import (
"context"
"errors"
)
func restartSystemdUnit(ctx context.Context) error {
return errors.ErrUnsupported
}

View File

@@ -102,7 +102,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("}")
writef("dst := new(%s)", name)
writef("*dst = *src")
for i := range t.NumFields() {
for i := 0; i < t.NumFields(); i++ {
fname := t.Field(i).Name()
ft := t.Field(i).Type()
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {

View File

@@ -8,7 +8,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -19,20 +18,36 @@ import (
"tailscale.com/tailcfg"
)
// storeDeviceID writes deviceID to 'device_id' data field of the named
// Kubernetes Secret.
func storeDeviceID(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID) error {
s := &kube.Secret{
Data: map[string][]byte{
"device_id": []byte(deviceID),
},
// findKeyInKubeSecret inspects the kube secret secretName for a data
// field called "authkey", and returns its value if present.
func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error) {
s, err := kc.GetSecret(ctx, secretName)
if err != nil {
return "", err
}
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
ak, ok := s.Data["authkey"]
if !ok {
return "", nil
}
return string(ak), nil
}
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields
// 'device_ips', 'device_fqdn' of the named Kubernetes Secret.
func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, addresses []netip.Prefix) error {
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
// secret secretName.
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string, addresses []netip.Prefix) error {
// First check if the secret exists at all. Even if running on
// kubernetes, we do not necessarily store state in a k8s secret.
if _, err := kc.GetSecret(ctx, secretName); err != nil {
if s, ok := err.(*kube.Status); ok {
if s.Code >= 400 && s.Code <= 499 {
// Assume the secret doesn't exist, or we don't have
// permission to access it.
return nil
}
}
return err
}
var ips []string
for _, addr := range addresses {
ips = append(ips, addr.Addr().String())
@@ -42,13 +57,14 @@ func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, a
return err
}
s := &kube.Secret{
m := &kube.Secret{
Data: map[string][]byte{
"device_id": []byte(deviceID),
"device_fqdn": []byte(fqdn),
"device_ips": deviceIPs,
},
}
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container")
}
// deleteAuthKey deletes the 'authkey' field of the given kube
@@ -72,59 +88,9 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
return nil
}
var kc kube.Client
var kc *kube.Client
// setupKube is responsible for doing any necessary configuration and checks to
// ensure that tailscale state storage and authentication mechanism will work on
// Kubernetes.
func (cfg *settings) setupKube(ctx context.Context) error {
if cfg.KubeSecret == "" {
return nil
}
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
if err != nil {
return fmt.Errorf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
cfg.KubernetesCanPatch = canPatch
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
if err != nil && kube.IsNotFoundErr(err) && !canCreate {
return fmt.Errorf("Tailscale state Secret %s does not exist and we don't have permissions to create it. "+
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
} else if err != nil && !kube.IsNotFoundErr(err) {
return fmt.Errorf("Getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
}
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
if s == nil {
log.Print("TS_AUTHKEY not provided and kube secret does not exist, login will be interactive if needed.")
return nil
}
keyBytes, _ := s.Data["authkey"]
key := string(keyBytes)
if key != "" {
// This behavior of pulling authkeys from kube secrets was added
// at the same time as the patch permission, so we can enforce
// that we must be able to patch out the authkey after
// authenticating if you want to use this feature. This avoids
// us having to deal with the case where we might leave behind
// an unnecessary reusable authkey in a secret, like a rake in
// the grass.
if !cfg.KubernetesCanPatch {
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
}
cfg.AuthKey = key
} else {
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
}
}
return nil
}
func initKubeClient(root string) {
func initKube(root string) {
if root != "/" {
// If we are running in a test, we need to set the root path to the fake
// service account directory.
@@ -135,9 +101,9 @@ func initKubeClient(root string) {
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
// Used to set http server in tests, or optionally enabled by flag
if root != "/" {
// If we are running in a test, we need to set the URL to the
// httptest server.
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
}

View File

@@ -1,206 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"errors"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/kube"
)
func TestSetupKube(t *testing.T) {
tests := []struct {
name string
cfg *settings
wantErr bool
wantCfg *settings
kc kube.Client
}{
{
name: "TS_AUTHKEY set, state Secret exists",
cfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, nil
},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
},
{
name: "TS_AUTHKEY set, state Secret does not exist, we have permissions to create it",
cfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, true, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
},
{
name: "TS_AUTHKEY set, state Secret does not exist, we do not have permissions to create it",
cfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
wantErr: true,
},
{
name: "TS_AUTHKEY set, we encounter a non-404 error when trying to retrieve the state Secret",
cfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 403}
},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
wantErr: true,
},
{
name: "TS_AUTHKEY set, we encounter a non-404 error when trying to check Secret permissions",
cfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, errors.New("broken")
},
},
wantErr: true,
},
{
// Interactive login using URL in Pod logs
name: "TS_AUTHKEY not set, state Secret does not exist, we have permissions to create it",
cfg: &settings{
KubeSecret: "foo",
},
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, true, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
},
},
},
{
// Interactive login using URL in Pod logs
name: "TS_AUTHKEY not set, state Secret exists, but does not contain auth key",
cfg: &settings{
KubeSecret: "foo",
},
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{}, nil
},
},
},
{
name: "TS_AUTHKEY not set, state Secret contains auth key, we do not have RBAC to patch it",
cfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
},
},
wantCfg: &settings{
KubeSecret: "foo",
},
wantErr: true,
},
{
name: "TS_AUTHKEY not set, state Secret contains auth key, we have RBAC to patch it",
cfg: &settings{
KubeSecret: "foo",
},
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return true, false, nil
},
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
},
},
wantCfg: &settings{
KubeSecret: "foo",
AuthKey: "foo",
KubernetesCanPatch: true,
},
},
}
for _, tt := range tests {
kc = tt.kc
t.Run(tt.name, func(t *testing.T) {
if err := tt.cfg.setupKube(context.Background()); (err != nil) != tt.wantErr {
t.Errorf("settings.setupKube() error = %v, wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(*tt.cfg, *tt.wantCfg); diff != "" {
t.Errorf("unexpected contents of settings after running settings.setupKube()\n(-got +want):\n%s", diff)
}
})
}
}

View File

@@ -18,11 +18,7 @@
// previously advertised routes. To accept routes, use TS_EXTRA_ARGS to pass
// in --accept-routes.
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
// destination defined by an IP address.
// - TS_EXPERIMENTAL_DEST_DNS_NAME: proxy all incoming Tailscale traffic to the given
// destination defined by a DNS name. The DNS name will be periodically resolved and firewall rules updated accordingly.
// This is currently intended to be used by the Kubernetes operator (ExternalName Services).
// This is an experimental env var and will likely change in the future.
// destination.
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
// destination defined by an IP.
// - TS_TAILNET_TARGET_FQDN: proxy all incoming non-Tailscale traffic to the given
@@ -52,29 +48,13 @@
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
// and will be re-applied when it changes.
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
// directory that containers tailscaled config in file. The config file needs to be
// named cap-<current-tailscaled-cap>.hujson. If this is set, TS_HOSTNAME,
// TS_EXTRA_ARGS, TS_AUTHKEY,
// - EXPERIMENTAL_TS_CONFIGFILE_PATH: if specified, a path to tailscaled
// config. If this is set, TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY,
// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set,
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
// and not `tailscale up` or `tailscale set`.
// The config file contents are currently read once on container start.
// NB: This env var is currently experimental and the logic will likely change!
// TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS: set to true to
// autoconfigure the default network interface for optimal performance for
// Tailscale subnet router/exit node.
// https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
// NB: This env var is currently experimental and the logic will likely change!
// - EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS: if set to true
// and if this containerboot instance is an L7 ingress proxy (created by
// the Kubernetes operator), set up rules to allow proxying cluster traffic,
// received on the Pod IP of this node, to the ingress target in the cluster.
// This, in conjunction with MagicDNS name resolution in cluster, can be
// useful for cases where a cluster workload needs to access a target in
// cluster using the same hostname (in this case, the MagicDNS name of the ingress proxy)
// as a non-cluster workload on tailnet.
// This is only meant to be configured by the Kubernetes operator.
//
// When running on Kubernetes, containerboot defaults to storing state in the
// "tailscale" kube secret. To store state on local disk instead, set
@@ -93,16 +73,12 @@ import (
"fmt"
"io/fs"
"log"
"math"
"net"
"net/netip"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"sync"
@@ -115,7 +91,6 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"
kubeutils "tailscale.com/k8s-operator"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/ptr"
@@ -133,33 +108,29 @@ func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
func main() {
log.SetPrefix("boot: ")
tailscale.I_Acknowledge_This_API_Is_Unstable = true
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
TailscaledConfigFilePath: tailscaledConfigFilePath(),
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
}
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
TailscaledConfigFilePath: defaultEnv("EXPERIMENTAL_TS_CONFIGFILE_PATH", ""),
}
if err := cfg.validate(); err != nil {
log.Fatalf("invalid configuration: %v", err)
}
@@ -168,8 +139,8 @@ func main() {
if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("Unable to create tuntap device file: %v", err)
}
if cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.Routes != nil || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTargetIP, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
if cfg.ProxyTo != "" || cfg.Routes != nil || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
log.Printf("Failed to enable IP forwarding: %v", err)
log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.")
if cfg.InKubernetes {
@@ -181,16 +152,44 @@ func main() {
}
}
if cfg.InKubernetes {
initKube(cfg.Root)
}
// Context is used for all setup stuff until we're in steady
// state, so that if something is hanging we eventually time out
// and crashloop the container.
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if cfg.InKubernetes {
initKubeClient(cfg.Root)
if err := cfg.setupKube(bootCtx); err != nil {
log.Fatalf("error setting up for running on Kubernetes: %v", err)
if cfg.InKubernetes && cfg.KubeSecret != "" {
canPatch, err := kc.CheckSecretPermissions(bootCtx, cfg.KubeSecret)
if err != nil {
log.Fatalf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
cfg.KubernetesCanPatch = canPatch
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret)
if err != nil {
log.Fatalf("Getting authkey from kube secret: %v", err)
}
if key != "" {
// This behavior of pulling authkeys from kube secrets was added
// at the same time as the patch permission, so we can enforce
// that we must be able to patch out the authkey after
// authenticating if you want to use this feature. This avoids
// us having to deal with the case where we might leave behind
// an unnecessary reusable authkey in a secret, like a rake in
// the grass.
if !cfg.KubernetesCanPatch {
log.Fatalf("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
}
log.Print("Using authkey found in kube secret")
cfg.AuthKey = key
} else {
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
}
}
}
@@ -205,12 +204,6 @@ func main() {
}
defer killTailscaled()
if cfg.EnableForwardingOptimizations {
if err := client.SetUDPGROForwarding(bootCtx); err != nil {
log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err)
}
}
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
if err != nil {
log.Fatalf("failed to watch tailscaled for updates: %v", err)
@@ -321,7 +314,7 @@ authLoop:
}
}
if hasKubeStateStore(cfg) && isTwoStepConfigAuthOnce(cfg) {
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && isTwoStepConfigAuthOnce(cfg) {
// We were told to only auth once, so any secret-bound
// authkey is no longer needed. We don't strictly need to
// wipe it, but it's good hygiene.
@@ -337,16 +330,14 @@ authLoop:
}
var (
startupTasksDone = false
currentIPs deephash.Sum // tailscale IPs assigned to device
currentDeviceID deephash.Sum // device ID
currentDeviceEndpoints deephash.Sum // device FQDN and IPs
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != ""
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
startupTasksDone = false
currentIPs deephash.Sum // tailscale IPs assigned to device
currentDeviceInfo deephash.Sum // device ID and fqdn
currentEgressIPs deephash.Sum
addrs []netip.Prefix
backendAddrs []net.IP
certDomain = new(atomic.Pointer[string])
certDomainChanged = make(chan bool, 1)
)
@@ -354,50 +345,12 @@ authLoop:
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
}
var nfr linuxfw.NetfilterRunner
if isL3Proxy(cfg) {
if wantProxy {
nfr, err = newNetfilterRunner(log.Printf)
if err != nil {
log.Fatalf("error creating new netfilter runner: %v", err)
}
}
// Setup for proxies that are configured to proxy to a target specified
// by a DNS name (TS_EXPERIMENTAL_DEST_DNS_NAME).
const defaultCheckPeriod = time.Minute * 10 // how often to check what IPs the DNS name resolves to
var (
tc = make(chan string, 1)
failedResolveAttempts int
t *time.Timer = time.AfterFunc(defaultCheckPeriod, func() {
if cfg.ProxyTargetDNSName != "" {
tc <- "recheck"
}
})
)
defer t.Stop()
// resetTimer resets timer for when to next attempt to resolve the DNS
// name for the proxy configured with TS_EXPERIMENTAL_DEST_DNS_NAME. The
// timer gets reset to 10 minutes from now unless the last resolution
// attempt failed. If one or more consecutive previous resolution
// attempts failed, the next resolution attempt will happen after the smallest
// of (10 minutes, 2 ^ number-of-consecutive-failed-resolution-attempts
// seconds) i.e 2s, 4s, 8s ... 10 minutes.
resetTimer := func(lastResolveFailed bool) {
if !lastResolveFailed {
log.Printf("reconfigureTimer: next DNS resolution attempt in %s", defaultCheckPeriod)
t.Reset(defaultCheckPeriod)
failedResolveAttempts = 0
return
}
minDelay := 2 // 2 seconds
nextTick := time.Second * time.Duration(math.Pow(float64(minDelay), float64(failedResolveAttempts)))
if nextTick > defaultCheckPeriod {
nextTick = defaultCheckPeriod // cap at 10 minutes
}
log.Printf("reconfigureTimer: last DNS resolution attempt failed, next DNS resolution attempt in %v", nextTick)
t.Reset(nextTick)
failedResolveAttempts++
}
notifyChan := make(chan ipn.Notify)
errChan := make(chan error)
go func() {
@@ -412,7 +365,6 @@ authLoop:
}
}()
var wg sync.WaitGroup
runLoop:
for {
select {
@@ -435,24 +387,10 @@ runLoop:
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.NetMap != nil {
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
// Store device ID in a Kubernetes Secret before
// setting up any routing rules. This ensures
// that, for containerboot instances that are
// Kubernetes operator proxies, the operator is
// able to retrieve the device ID from the
// Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously
// fails.
deviceID := n.NetMap.SelfNode.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := storeDeviceID(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID()); err != nil {
log.Fatalf("storing device ID in Kubernetes Secret: %v", err)
}
}
if cfg.TailnetTargetFQDN != "" {
var (
egressAddrs []netip.Prefix
@@ -475,7 +413,7 @@ runLoop:
egressAddrs = node.Addresses().AsSlice()
newCurentEgressIPs = deephash.Hash(&egressAddrs)
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
if egressIPsHaveChanged && len(egressAddrs) != 0 {
if egressIPsHaveChanged && len(egressAddrs) > 0 {
for _, egressAddr := range egressAddrs {
ea := egressAddr.Addr()
// TODO (irbekrm): make it work for IPv6 too.
@@ -491,32 +429,13 @@ runLoop:
}
currentEgressIPs = newCurentEgressIPs
}
if cfg.ProxyTargetIP != "" && len(addrs) != 0 && ipsHaveChanged {
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTargetIP, addrs, nfr); err != nil {
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
if err != nil {
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
resetTimer(true)
continue
}
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
}))
if backendsHaveChanged {
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
log.Fatalf("error installing ingress proxy rules: %v", err)
}
}
resetTimer(false)
backendAddrs = newBackendAddrs
}
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) != 0 {
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) > 0 {
cd := n.NetMap.DNS.CertDomains[0]
prev := certDomain.Swap(ptr.To(cd))
if prev == nil || *prev != cd {
@@ -526,105 +445,54 @@ runLoop:
}
}
}
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 {
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
log.Fatalf("installing egress proxy rules: %v", err)
}
}
// If this is a L7 cluster ingress proxy (set up
// by Kubernetes operator) and proxying of
// cluster traffic to the ingress target is
// enabled, set up proxy rule each time the
// tailnet IPs of this node change (including
// the first time they become available).
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 {
log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP)
if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil {
log.Fatalf("installing rules to forward traffic to node's tailnet IP: %v", err)
}
}
currentIPs = newCurrentIPs
// Only store device FQDN and IP addresses to
// Kubernetes Secret when any required proxy
// route setup has succeeded. IPs and FQDN are
// read from the Secret by the Tailscale
// Kubernetes operator and, for some proxy
// types, such as Tailscale Ingress, advertized
// on the Ingress status. Writing them to the
// Secret only after the proxy routing has been
// set up ensures that the operator does not
// advertize endpoints of broken proxies.
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := storeDeviceEndpoints(ctx, cfg.KubeSecret, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
log.Fatalf("storing device IPs and FQDN in Kubernetes Secret: %v", err)
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(&currentDeviceInfo, &deviceInfo) {
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
log.Fatalf("storing device ID in kube secret: %v", err)
}
}
}
if !startupTasksDone {
// For containerboot instances that act as TCP
// proxies (proxying traffic to an endpoint
// passed via one of the env vars that
// containerbot reads) and store state in a
// Kubernetes Secret, we consider startup tasks
// done at the point when device info has been
// successfully stored to state Secret.
// For all other containerboot instances, if we
// just get to this point the startup tasks can
// be considered done.
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
// Wait on tailscaled process. It won't
// be cleaned up by default when the
// container exits as it is not PID1.
// TODO (irbekrm): perhaps we can
// replace the reaper by a running
// cmd.Wait in a goroutine immediately
// after starting tailscaled?
// Reap all processes, since we are PID1 and need to collect zombies. We can
// only start doing this once we've stopped shelling out to things
// `tailscale up`, otherwise this goroutine can reap the CLI subprocesses
// and wedge bringup.
reaper := func() {
defer wg.Done()
for {
var status unix.WaitStatus
_, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil)
pid, err := unix.Wait4(-1, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for tailscaled to exit: %v", err)
log.Fatalf("Waiting for exited processes: %v", err)
}
if pid == daemonProcess.Pid {
log.Printf("Tailscaled exited")
os.Exit(0)
}
log.Print("tailscaled exited")
os.Exit(0)
}
}
wg.Add(1)
go reaper()
}
}
case <-tc:
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
if err != nil {
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
resetTimer(true)
continue
}
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
}))
if backendsHaveChanged && len(addrs) != 0 {
log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
log.Fatalf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
}
}
backendAddrs = newBackendAddrs
resetTimer(false)
}
}
wg.Wait()
@@ -865,12 +733,12 @@ func ensureTunFile(root string) error {
}
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTargetFQDN string, routes *string) error {
var (
v4Forwarding, v6Forwarding bool
)
if clusterProxyTargetIP != "" {
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
if clusterProxyTarget != "" {
proxyIP, err := netip.ParseAddr(clusterProxyTarget)
if err != nil {
return fmt.Errorf("invalid cluster destination IP: %v", err)
}
@@ -880,8 +748,8 @@ func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTarg
v6Forwarding = true
}
}
if tailnetTargetIP != "" {
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
if tailnetTargetiP != "" {
proxyIP, err := netip.ParseAddr(tailnetTargetiP)
if err != nil {
return fmt.Errorf("invalid tailnet destination IP: %v", err)
}
@@ -909,10 +777,7 @@ func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTarg
}
}
}
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
}
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
var paths []string
if v4Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
@@ -972,58 +837,22 @@ func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []net
return nil
}
// installTSForwardingRuleForDestination accepts a destination address and a
// list of node's tailnet addresses, sets up rules to forward traffic for
// destination to the tailnet IP matching the destination IP family.
// Destination can be Pod IP of this node.
func installTSForwardingRuleForDestination(ctx context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstFilter)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
}
if err := nfr.AddDNATRule(dst, local); err != nil {
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
}
return nil
}
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
proxyHasIPv4Address := false
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() {
proxyHasIPv4Address = true
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if proxyHasIPv4Address && dst.Is6() {
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
@@ -1036,89 +865,15 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
return nil
}
func installIngressForwardingRuleForDNSTarget(ctx context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
var (
tsv4 netip.Addr
tsv6 netip.Addr
v4Backends []netip.Addr
v6Backends []netip.Addr
)
for _, pfx := range tsIPs {
if pfx.IsSingleIP() && pfx.Addr().Is4() {
tsv4 = pfx.Addr()
continue
}
if pfx.IsSingleIP() && pfx.Addr().Is6() {
tsv6 = pfx.Addr()
continue
}
}
// TODO: log if more than one backend address is found and firewall is
// in nftables mode that only the first IP will be used.
for _, ip := range backendAddrs {
if ip.To4() != nil {
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
}
if ip.To16() != nil {
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
}
}
// Enable IP forwarding here as opposed to at the start of containerboot
// as the IPv4/IPv6 requirements might have changed.
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
// enabled by an init container, so in practice enabling forwarding here
// is only needed if this proxy has been configured by manually setting
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
}
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
}
// The backend might advertize MSS higher than that of the
// tailscale interfaces. Clamp MSS of packets going out via
// tailscale0 interface to its MTU to prevent broken connections
// in environments where path MTU discovery is not working.
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
}
return nil
}
if len(v4Backends) != 0 {
if !tsv4.IsValid() {
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
}
}
if len(v6Backends) != 0 && !tsv6.IsValid() {
if !tsv6.IsValid() {
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
} else if !nfr.HasIPV6NAT() {
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
}
}
return nil
}
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes *string
// ProxyTargetIP is the destination IP to which all incoming
// ProxyTo is the destination IP to which all incoming
// Tailscale traffic should be proxied. If empty, no proxying
// is done. This is typically a locally reachable IP.
ProxyTargetIP string
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
// incoming Tailscale traffic should be proxied.
ProxyTargetDNSName string
ProxyTo string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. This is typically a
// Tailscale IP.
@@ -1126,55 +881,33 @@ type settings struct {
// TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN.
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS *bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailscaledConfigFilePath string
EnableForwardingOptimizations bool
// If set to true and, if this containerboot instance is a Kubernetes
// ingress proxy, set up rules to forward incoming cluster traffic to be
// forwarded to the ingress target in cluster.
AllowProxyingClusterTrafficViaIngress bool
// PodIP is the IP of the Pod if running in Kubernetes. This is used
// when setting up rules to proxy cluster traffic to cluster ingress
// target.
PodIP string
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS *bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailscaledConfigFilePath string
}
func (s *settings) validate() error {
if s.TailscaledConfigFilePath != "" {
dir, file := path.Split(s.TailscaledConfigFilePath)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
}
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
}
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
}
}
if s.ProxyTargetIP != "" && s.UserspaceMode {
if s.ProxyTo != "" && s.UserspaceMode {
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
}
if s.TailnetTargetIP != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
@@ -1185,45 +918,11 @@ func (s *settings) validate() error {
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
}
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
}
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
}
if s.EnableForwardingOptimizations && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
return errors.New("EXPERIMENTAL_TS_CONFIGFILE_PATH cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
return nil
}
func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
// TODO (irbekrm): look at using recursive.Resolver instead to resolve
// the DNS names as well as retrieve TTLs. It looks though that this
// seems to return very short TTLs (shorter than on the actual records).
ip4s, err := net.DefaultResolver.LookupIP(ctx, "ip4", name)
if err != nil {
if e, ok := err.(*net.DNSError); !(ok && e.IsNotFound) {
return nil, fmt.Errorf("error looking up IPv4 addresses: %v", err)
}
}
ip6s, err := net.DefaultResolver.LookupIP(ctx, "ip6", name)
if err != nil {
if e, ok := err.(*net.DNSError); !(ok && e.IsNotFound) {
return nil, fmt.Errorf("error looking up IPv6 addresses: %v", err)
}
}
if len(ip4s) == 0 && len(ip6s) == 0 {
return nil, fmt.Errorf("no IPv4 or IPv6 addresses found for host: %s", name)
}
return append(ip4s, ip6s...), nil
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {
@@ -1320,55 +1019,3 @@ func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
func isOneStepConfig(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}
// isL3Proxy returns true if the Tailscale node needs to be configured to act
// as an L3 proxy, proxying to an endpoint provided via one of the config env
// vars.
func isL3Proxy(cfg *settings) bool {
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
}
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
// Secret.
func hasKubeStateStore(cfg *settings) bool {
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
}
// tailscaledConfigFilePath returns the path to the tailscaled config file that
// should be used for the current capability version. It is determined by the
// TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR environment variable and looks for a
// file named cap-<capability_version>.hujson in the directory. It searches for
// the highest capability version that is less than or equal to the current
// capability version.
func tailscaledConfigFilePath() string {
dir := os.Getenv("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR")
if dir == "" {
return ""
}
fe, err := os.ReadDir(dir)
if err != nil {
log.Fatalf("error reading tailscaled config directory %q: %v", dir, err)
}
maxCompatVer := tailcfg.CapabilityVersion(-1)
for _, e := range fe {
// We don't check if type if file as in most cases this will
// come from a mounted kube Secret, where the directory contents
// will be various symlinks.
if e.Type().IsDir() {
continue
}
cv, err := kubeutils.CapVerFromFileName(e.Name())
if err != nil {
log.Printf("skipping file %q in tailscaled config directory %q: %v", e.Name(), dir, err)
continue
}
if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion {
maxCompatVer = cv
}
}
if maxCompatVer == -1 {
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
}
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
return path.Join(dir, kubeutils.TailscaledConfigFileNameForCap(maxCompatVer))
}

View File

@@ -65,7 +65,7 @@ func TestContainerBoot(t *testing.T) {
"dev/net",
"proc/sys/net/ipv4",
"proc/sys/net/ipv6/conf/all",
"etc/tailscaled",
"etc",
}
for _, path := range dirs {
if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
@@ -80,7 +80,7 @@ func TestContainerBoot(t *testing.T) {
"dev/net/tun": []byte(""),
"proc/sys/net/ipv4/ip_forward": []byte("0"),
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
"etc/tailscaled/cap-95.hujson": tailscaledConfBytes,
"etc/tailscaled": tailscaledConfBytes,
}
resetFiles := func() {
for path, content := range files {
@@ -638,14 +638,14 @@ func TestContainerBoot(t *testing.T) {
},
},
{
Name: "experimental tailscaled config path",
Name: "experimental tailscaled configfile",
Env: map[string]string{
"TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR": filepath.Join(d, "etc/tailscaled/"),
"EXPERIMENTAL_TS_CONFIGFILE_PATH": filepath.Join(d, "etc/tailscaled"),
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled/cap-95.hujson",
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled",
},
}, {
Notify: runningNotify,

View File

@@ -1,74 +0,0 @@
# DERP
This is the code for the [Tailscale DERP server](https://tailscale.com/kb/1232/derp-servers).
In general, you should not need to nor want to run this code. The overwhelming majority of Tailscale users (both individuals and companies) do not.
In the happy path, Tailscale establishes direct connections between peers and
data plane traffic flows directly between them, without using DERP for more than
acting as a low bandwidth side channel to bootstrap the NAT traversal. If you
find yourself wanting DERP for more bandwidth, the real problem is usually the
network configuration of your Tailscale node(s), making sure that Tailscale can
get direction connections via some mechanism.
But if you've decided or been advised to run your own `derper`, then read on.
## Caveats
* Node sharing and other cross-Tailnet features don't work when using custom
DERP servers.
* DERP servers only see encrypted WireGuard packets and thus are not useful for
network-level debugging.
* The Tailscale control plane does certain geo-level steering features and
optimizations that are not available when using custom DERP servers.
## Guide to running `cmd/derper`
* You must build and update the `cmd/derper` binary yourself. There are no
packages. Use `go install tailscale.com/cmd/derper@latest` with the latest
version of Go.
* The DERP protocol does a protocol switch inside TLS from HTTP to a custom
bidirectional binary protocol. It is thus incompatible with many HTTP proxies.
Do not put `derper` behind another HTTP proxy.
* The `tailscaled` client does its own selection of the fastest/nearest DERP
server based on latency measurements. Do not put `derper` behind a global load
balancer.
* DERP servers should ideally have both a static IPv4 and static IPv6 address.
Both of those should be listed in the DERP map so the client doesn't need to
rely on its DNS which might be broken and dependent on DERP to get back up.
* A DERP server should not share an IP address with any other DERP server.
* Avoid having multiple DERP nodes in a region. If you must, they all need to be
meshed with each other and monitored. Having two one-node "regions" in the
same datacenter is usually easier and more reliable than meshing, at the cost
of more required connections from clients in some cases. If your clients
aren't mobile (battery constrained), one node regions are definitely
preferred. If you really need multiple nodes in a region for HA reasons, two
is sufficient.
* Monitor your DERP servers with [`cmd/derpprobe`](../derpprobe/).
* If using `--verify-clients`, a `tailscaled` must be running alongside the
`derper`.
* If using `--verify-clients`, a `tailscaled` must also be running alongside
your `derpprobe`, and `derpprobe` needs to use `--derp-map=local`.
* The firewall on the `derper` should permit TCP ports 80 and 443 and UDP port
3478.
* Only LetsEncrypt certs are rotated automatically. Other cert updates require a
restart.
* Don't use a firewall in front of `derper` that suppresses `RST`s upon
receiving traffic to a dead or unknown connection.
* Don't rate-limit UDP STUN packets.
* Don't rate-limit outbound TCP traffic (only inbound).

View File

@@ -5,45 +5,35 @@ package main
import (
"context"
"encoding/binary"
"encoding/json"
"expvar"
"log"
"math/rand/v2"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync/atomic"
"time"
"tailscale.com/syncs"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
)
const refreshTimeout = time.Minute
type dnsEntryMap struct {
IPs map[string][]net.IP
Percent map[string]float64 // "foo.com" => 0.5 for 50%
}
type dnsEntryMap map[string][]net.IP
var (
dnsCache atomic.Pointer[dnsEntryMap]
dnsCache syncs.AtomicValue[dnsEntryMap]
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
unpublishedDNSCache atomic.Pointer[dnsEntryMap]
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
bootstrapLookupMap syncs.Map[string, bool]
)
var (
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
unpublishedDNSPercentMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_percent_misses")
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
)
func init() {
@@ -69,13 +59,15 @@ func refreshBootstrapDNS() {
}
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
dnsEntries := resolveList(ctx, *bootstrapDNS)
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
// Randomize the order of the IPs for each name to avoid the client biasing
// to IPv6
for _, vv := range dnsEntries.IPs {
slicesx.Shuffle(vv)
for k := range dnsEntries {
ips := dnsEntries[k]
slicesx.Shuffle(ips)
dnsEntries[k] = ips
}
j, err := json.MarshalIndent(dnsEntries.IPs, "", "\t")
j, err := json.MarshalIndent(dnsEntries, "", "\t")
if err != nil {
// leave the old values in place
return
@@ -89,50 +81,27 @@ func refreshUnpublishedDNS() {
if *unpublishedDNS == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
dnsEntries := resolveList(ctx, *unpublishedDNS)
dnsEntries := resolveList(ctx, strings.Split(*unpublishedDNS, ","))
unpublishedDNSCache.Store(dnsEntries)
}
// resolveList takes a comma-separated list of DNS names to resolve.
//
// If an entry contains a slash, it's two DNS names: the first is the one to
// resolve and the second is that of a TXT recording containing the rollout
// percentage in range "0".."100". If the TXT record doesn't exist or is
// malformed, the percentage is 0. If the TXT record is not provided (there's no
// slash), then the percentage is 100.
func resolveList(ctx context.Context, list string) *dnsEntryMap {
ents := strings.Split(list, ",")
ret := &dnsEntryMap{}
func resolveList(ctx context.Context, names []string) dnsEntryMap {
dnsEntries := make(dnsEntryMap)
var r net.Resolver
for _, ent := range ents {
name, txtName, _ := strings.Cut(ent, "/")
for _, name := range names {
addrs, err := r.LookupIP(ctx, "ip", name)
if err != nil {
log.Printf("bootstrap DNS lookup %q: %v", name, err)
continue
}
mak.Set(&ret.IPs, name, addrs)
if txtName == "" {
mak.Set(&ret.Percent, name, 1.0)
continue
}
vals, err := r.LookupTXT(ctx, txtName)
if err != nil {
log.Printf("bootstrap DNS lookup %q: %v", txtName, err)
continue
}
for _, v := range vals {
if v, err := strconv.Atoi(v); err == nil && v >= 0 && v <= 100 {
mak.Set(&ret.Percent, name, float64(v)/100)
}
}
dnsEntries[name] = addrs
}
return ret
return dnsEntries
}
func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
@@ -146,36 +115,22 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
// Try answering a query from our hidden map first
if q := r.URL.Query().Get("q"); q != "" {
bootstrapLookupMap.Store(q, true)
if bootstrapLookupMap.Len() > 500 { // defensive
bootstrapLookupMap.Clear()
}
if m := unpublishedDNSCache.Load(); m != nil && len(m.IPs[q]) > 0 {
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
unpublishedDNSHits.Add(1)
percent := m.Percent[q]
if remoteAddrMatchesPercent(r.RemoteAddr, percent) {
// Only return the specific query, not everything.
m := map[string][]net.IP{q: m.IPs[q]}
j, err := json.MarshalIndent(m, "", "\t")
if err == nil {
w.Write(j)
return
}
} else {
unpublishedDNSPercentMisses.Add(1)
// Only return the specific query, not everything.
m := dnsEntryMap{q: ips}
j, err := json.MarshalIndent(m, "", "\t")
if err == nil {
w.Write(j)
return
}
}
// If we have a "q" query for a name in the published cache
// list, then track whether that's a hit/miss.
m := dnsCache.Load()
var inPub bool
var ips []net.IP
if m != nil {
ips, inPub = m.IPs[q]
}
if inPub {
if len(ips) > 0 {
if m, ok := dnsCache.Load()[q]; ok {
if len(m) > 0 {
publishedDNSHits.Add(1)
} else {
publishedDNSMisses.Add(1)
@@ -191,29 +146,3 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
j := dnsCacheBytes.Load()
w.Write(j)
}
// percent is [0.0, 1.0].
func remoteAddrMatchesPercent(remoteAddr string, percent float64) bool {
if percent == 0 {
return false
}
if percent == 1 {
return true
}
reqIPStr, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return false
}
reqIP, err := netip.ParseAddr(reqIPStr)
if err != nil {
return false
}
if reqIP.IsLoopback() {
// For local testing.
return rand.Float64() < 0.5
}
reqIP16 := reqIP.As16()
rndSrc := rand.NewPCG(binary.LittleEndian.Uint64(reqIP16[:8]), binary.LittleEndian.Uint64(reqIP16[8:]))
rnd := rand.New(rndSrc)
return percent > rnd.Float64()
}

View File

@@ -4,19 +4,15 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"reflect"
"testing"
"tailscale.com/tstest"
"tailscale.com/tstest/nettest"
)
func BenchmarkHandleBootstrapDNS(b *testing.B) {
@@ -41,7 +37,7 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
t.Helper()
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
w := httptest.NewRecorder()
@@ -51,17 +47,14 @@ func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
if res.StatusCode != 200 {
t.Fatalf("got status=%d; want %d", res.StatusCode, 200)
}
var m map[string][]net.IP
var buf bytes.Buffer
if err := json.NewDecoder(io.TeeReader(res.Body, &buf)).Decode(&m); err != nil {
t.Fatalf("error decoding response body %q: %v", buf.Bytes(), err)
var ips dnsEntryMap
if err := json.NewDecoder(res.Body).Decode(&ips); err != nil {
t.Fatalf("error decoding response body: %v", err)
}
return m
return ips
}
func TestUnpublishedDNS(t *testing.T) {
nettest.SkipIfNoNetwork(t)
const published = "login.tailscale.com"
const unpublished = "log.tailscale.io"
@@ -111,21 +104,15 @@ func resetMetrics() {
// Verify that we don't count an empty list in the unpublishedDNSCache as a
// cache hit in our metrics.
func TestUnpublishedDNSEmptyList(t *testing.T) {
pub := &dnsEntryMap{
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
pub := dnsEntryMap{
"tailscale.com": {net.IPv4(10, 10, 10, 10)},
}
dnsCache.Store(pub)
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
unpublishedDNSCache.Store(&dnsEntryMap{
IPs: map[string][]net.IP{
"log.tailscale.io": {},
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
},
Percent: map[string]float64{
"log.tailscale.io": 1.0,
"controlplane.tailscale.com": 1.0,
},
unpublishedDNSCache.Store(dnsEntryMap{
"log.tailscale.io": {},
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
})
t.Run("CacheMiss", func(t *testing.T) {
@@ -135,8 +122,8 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
ips := getBootstrapDNS(t, q)
// Expected our public map to be returned on a cache miss
if !reflect.DeepEqual(ips, pub.IPs) {
t.Errorf("got ips=%+v; want %+v", ips, pub.IPs)
if !reflect.DeepEqual(ips, pub) {
t.Errorf("got ips=%+v; want %+v", ips, pub)
}
if v := unpublishedDNSHits.Value(); v != 0 {
t.Errorf("got hits=%d; want 0", v)
@@ -151,7 +138,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
t.Run("CacheHit", func(t *testing.T) {
resetMetrics()
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
want := dnsEntryMap{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
if !reflect.DeepEqual(ips, want) {
t.Errorf("got ips=%+v; want %+v", ips, want)
}
@@ -176,54 +163,3 @@ func TestLookupMetric(t *testing.T) {
t.Errorf("bootstrapLookupMap.Len() want=5, got %v", bootstrapLookupMap.Len())
}
}
func TestRemoteAddrMatchesPercent(t *testing.T) {
tests := []struct {
remoteAddr string
percent float64
want bool
}{
// 0% and 100%.
{"10.0.0.1:1234", 0.0, false},
{"10.0.0.1:1234", 1.0, true},
// Invalid IP.
{"", 1.0, true},
{"", 0.0, false},
{"", 0.5, false},
// Small manual sample at 50%. The func uses a deterministic PRNG seed.
{"1.2.3.4:567", 0.5, true},
{"1.2.3.5:567", 0.5, true},
{"1.2.3.6:567", 0.5, false},
{"1.2.3.7:567", 0.5, true},
{"1.2.3.8:567", 0.5, false},
{"1.2.3.9:567", 0.5, true},
{"1.2.3.10:567", 0.5, true},
}
for _, tt := range tests {
got := remoteAddrMatchesPercent(tt.remoteAddr, tt.percent)
if got != tt.want {
t.Errorf("remoteAddrMatchesPercent(%q, %v) = %v; want %v", tt.remoteAddr, tt.percent, got, tt.want)
}
}
var match, all int
const wantPercent = 0.5
for a := range 256 {
for b := range 256 {
all++
if remoteAddrMatchesPercent(
netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, byte(a), byte(b)}), 12345).String(),
wantPercent) {
match++
}
}
}
gotPercent := float64(match) / float64(all)
const tolerance = 0.005
t.Logf("got percent %v (goal %v)", gotPercent, wantPercent)
if gotPercent < wantPercent-tolerance || gotPercent > wantPercent+tolerance {
t.Errorf("got %v; want %v ± %v", gotPercent, wantPercent, tolerance)
}
}

View File

@@ -11,18 +11,21 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/util/fastuuid
github.com/google/uuid from tailscale.com/tsweb
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
github.com/klauspost/compress/flate from nhooyr.io/websocket
github.com/matttproud/golang_protobuf_extensions/pbutil from github.com/prometheus/common/expfmt
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
@@ -46,15 +49,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/net/tsaddr
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt
google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+
google.golang.org/protobuf/encoding/protowire from google.golang.org/protobuf/encoding/protodelim+
go4.org/netipx from tailscale.com/wgengine/filter+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+
google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+
google.golang.org/protobuf/internal/descfmt from google.golang.org/protobuf/internal/filedesc
google.golang.org/protobuf/internal/descopts from google.golang.org/protobuf/internal/filedesc+
google.golang.org/protobuf/internal/detrand from google.golang.org/protobuf/internal/descfmt+
google.golang.org/protobuf/internal/editiondefaults from google.golang.org/protobuf/internal/filedesc
google.golang.org/protobuf/internal/encoding/defval from google.golang.org/protobuf/internal/encoding/tag+
google.golang.org/protobuf/internal/encoding/messageset from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/encoding/tag from google.golang.org/protobuf/internal/impl
@@ -70,15 +71,16 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
google.golang.org/protobuf/internal/set from google.golang.org/protobuf/encoding/prototext
💣 google.golang.org/protobuf/internal/strs from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/version from google.golang.org/protobuf/runtime/protoimpl
google.golang.org/protobuf/proto from github.com/prometheus/client_golang/prometheus+
💣 google.golang.org/protobuf/reflect/protoreflect from github.com/prometheus/client_model/go+
google.golang.org/protobuf/reflect/protoregistry from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+
google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+
google.golang.org/protobuf/proto from github.com/golang/protobuf/proto+
google.golang.org/protobuf/reflect/protodesc from github.com/golang/protobuf/proto
💣 google.golang.org/protobuf/reflect/protoreflect from github.com/golang/protobuf/proto+
google.golang.org/protobuf/reflect/protoregistry from github.com/golang/protobuf/proto+
google.golang.org/protobuf/runtime/protoiface from github.com/golang/protobuf/proto+
google.golang.org/protobuf/runtime/protoimpl from github.com/golang/protobuf/proto+
google.golang.org/protobuf/types/descriptorpb from google.golang.org/protobuf/reflect/protodesc
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
nhooyr.io/websocket from tailscale.com/cmd/derper+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/cmd/derper+
@@ -87,20 +89,21 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/derp from tailscale.com/cmd/derper+
tailscale.com/derp/derphttp from tailscale.com/cmd/derper
tailscale.com/disco from tailscale.com/derp
tailscale.com/drive from tailscale.com/client/tailscale+
tailscale.com/envknob from tailscale.com/client/tailscale+
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/envknob from tailscale.com/derp+
tailscale.com/health from tailscale.com/net/tlsdial
tailscale.com/hostinfo from tailscale.com/net/interfaces+
tailscale.com/ipn from tailscale.com/client/tailscale
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/net/netns+
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netknob from tailscale.com/net/netns
💣 tailscale.com/net/netmon from tailscale.com/derp/derphttp+
tailscale.com/net/netmon from tailscale.com/net/sockstats+
tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/tailscale
tailscale.com/net/packet from tailscale.com/wgengine/filter
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/net/stunserver
tailscale.com/net/stunserver from tailscale.com/cmd/derper
@@ -114,17 +117,17 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/netmon
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/derp
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
tailscale.com/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/tailcfg+
tailscale.com/types/key from tailscale.com/client/tailscale+
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/cmd/derper+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn
@@ -133,36 +136,33 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/preftype from tailscale.com/ipn
tailscale.com/types/ptr from tailscale.com/hostinfo+
tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/tkatype from tailscale.com/client/tailscale+
tailscale.com/types/views from tailscale.com/ipn+
tailscale.com/util/cibuild from tailscale.com/health
tailscale.com/util/clientmetric from tailscale.com/net/netmon+
tailscale.com/types/tkatype from tailscale.com/types/key+
tailscale.com/types/views from tailscale.com/ipn/ipnstate+
tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy+
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/ctxkey from tailscale.com/tsweb+
tailscale.com/util/cmpx from tailscale.com/cmd/derper+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/fastuuid from tailscale.com/tsweb
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/health+
tailscale.com/util/mak from tailscale.com/syncs+
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/vizerror from tailscale.com/tailcfg+
tailscale.com/util/vizerror from tailscale.com/tsweb+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/envknob+
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
tailscale.com/version/distro from tailscale.com/hostinfo+
tailscale.com/wgengine/filter from tailscale.com/types/netmap
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/blake2s from tailscale.com/tka
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls
@@ -173,7 +173,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/nacl/box from tailscale.com/types/key
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
W golang.org/x/exp/constraints from tailscale.com/util/winutil
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http
@@ -183,10 +182,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from net+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sys/cpu from github.com/josharian/native+
LD golang.org/x/sys/unix from github.com/google/nftables+
W golang.org/x/sys/windows from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
LD golang.org/x/sys/unix from github.com/jsimonetti/rtnetlink/internal/unix+
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
@@ -198,10 +197,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
bytes from bufio+
cmp from slices+
compress/flate from compress/gzip+
compress/gzip from google.golang.org/protobuf/internal/impl+
compress/gzip from internal/profile+
container/list from crypto/tls+
context from crypto/tls+
crypto from crypto/ecdh+
crypto from crypto/ecdsa+
crypto/aes from crypto/ecdsa+
crypto/cipher from crypto/aes+
crypto/des from crypto/tls+
@@ -226,15 +225,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base32 from tailscale.com/tka+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
errors from bufio+
expvar from github.com/prometheus/client_golang/prometheus+
flag from tailscale.com/cmd/derper
expvar from tailscale.com/cmd/derper+
flag from tailscale.com/cmd/derper+
fmt from compress/flate+
go/token from google.golang.org/protobuf/internal/strs
hash from crypto+
@@ -247,13 +246,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
io/ioutil from github.com/mitchellh/go-ps+
log from expvar+
log/internal from log
maps from tailscale.com/ipn+
maps from tailscale.com/types/views+
math from compress/flate+
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+
math/rand/v2 from tailscale.com/util/fastuuid+
mime from github.com/prometheus/common/expfmt+
mime from mime/multipart+
mime/multipart from net/http
mime/quotedprintable from mime/multipart
net from crypto/tls+
@@ -265,18 +263,18 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+
os from crypto/rand+
os/exec from github.com/coreos/go-iptables/iptables+
os/exec from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
os/signal from tailscale.com/cmd/derper
W os/user from tailscale.com/util/winutil
path from github.com/prometheus/client_golang/prometheus/internal+
path from golang.org/x/crypto/acme/autocert+
path/filepath from crypto/x509+
reflect from crypto/x509+
regexp from github.com/coreos/go-iptables/iptables+
regexp from internal/profile+
regexp/syntax from regexp
runtime/debug from github.com/prometheus/client_golang/prometheus+
runtime/debug from golang.org/x/crypto/acme+
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof
runtime/trace from net/http/pprof
runtime/trace from net/http/pprof+
slices from tailscale.com/ipn/ipnstate+
sort from compress/flate+
strconv from compress/flate+
@@ -284,6 +282,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
sync from compress/flate+
sync/atomic from context+
syscall from crypto/rand+
testing from tailscale.com/util/syspolicy
text/tabwriter from runtime/pprof
time from compress/gzip+
unicode from bytes+

View File

@@ -5,7 +5,6 @@
package main // import "tailscale.com/cmd/derper"
import (
"cmp"
"context"
"crypto/tls"
"encoding/json"
@@ -26,47 +25,38 @@ import (
"syscall"
"time"
"go4.org/mem"
"golang.org/x/time/rate"
"tailscale.com/atomicfile"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/metrics"
"tailscale.com/net/ktimeout"
"tailscale.com/net/stunserver"
"tailscale.com/tsweb"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/version"
"tailscale.com/util/cmpx"
)
var (
dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)")
versionFlag = flag.Bool("version", false, "print version and exit")
addr = flag.String("a", ":443", "server HTTP/HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces. Serves HTTPS if the port is 443 and/or -certmode is manual, otherwise HTTP.")
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)")
addr = flag.String("a", ":443", "server HTTP/HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces. Serves HTTPS if the port is 443 and/or -certmode is manual, otherwise HTTP.")
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list. If an entry contains a slash, the second part names a DNS record to poll for its TXT record with a `0` to `100` value for rollout percentage.")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
verifyClientURL = flag.String("verify-client-url", "", "if non-empty, an admission controller URL for permitting client connections; see tailcfg.DERPAdmitClientRequest")
verifyFailOpen = flag.Bool("verify-client-url-fail-open", true, "whether we fail open if --verify-client-url is unreachable")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
// tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule.
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
// tcpUserTimeout is intentionally short, so that hung connections are cleaned up promptly. DERPs should be nearby users.
tcpUserTimeout = flag.Duration("tcp-user-timeout", 15*time.Second, "TCP user timeout")
)
var (
@@ -131,10 +121,6 @@ func writeNewConfig() config {
func main() {
flag.Parse()
if *versionFlag {
fmt.Println(version.Long())
return
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
@@ -161,8 +147,6 @@ func main() {
s := derp.NewServer(cfg.PrivateKey, log.Printf)
s.SetVerifyClient(*verifyClients)
s.SetVerifyClientURL(*verifyClientURL)
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
if *meshPSKFile != "" {
b, err := os.ReadFile(*meshPSKFile)
@@ -191,12 +175,7 @@ func main() {
http.Error(w, "derp server disabled", http.StatusNotFound)
}))
}
// These two endpoints are the same. Different versions of the clients
// have assumes different paths over time so we support both.
mux.HandleFunc("/derp/probe", derphttp.ProbeHandler)
mux.HandleFunc("/derp/latency-check", derphttp.ProbeHandler)
mux.HandleFunc("/derp/probe", probeHandler)
go refreshBootstrapDNSLoop()
mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -237,16 +216,7 @@ func main() {
}))
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
// Longer lived DERP connections send an application layer keepalive. Note
// if the keepalive is hit, the user timeout will take precedence over the
// keepalive counter, so the probe if unanswered will take effect promptly,
// this is less tolerant of high loss, but high loss is unexpected.
lc := net.ListenConfig{
Control: ktimeout.UserTimeout(*tcpUserTimeout),
KeepAlive: *tcpKeepAlive,
}
quietLogger := log.New(logger.HTTPServerLogFilter{Inner: log.Printf}, "", 0)
quietLogger := log.New(logFilter{}, "", 0)
httpsrv := &http.Server{
Addr: *addr,
Handler: mux,
@@ -322,12 +292,7 @@ func main() {
// duration exceeds server's WriteTimeout".
WriteTimeout: 5 * time.Minute,
}
ln, err := lc.Listen(context.Background(), "tcp", port80srv.Addr)
if err != nil {
log.Fatal(err)
}
defer ln.Close()
err = port80srv.Serve(ln)
err := port80srv.ListenAndServe()
if err != nil {
if err != http.ErrServerClosed {
log.Fatal(err)
@@ -335,15 +300,10 @@ func main() {
}
}()
}
err = rateLimitedListenAndServeTLS(httpsrv, &lc)
err = rateLimitedListenAndServeTLS(httpsrv)
} else {
log.Printf("derper: serving on %s", *addr)
var ln net.Listener
ln, err = lc.Listen(context.Background(), "tcp", httpsrv.Addr)
if err != nil {
log.Fatal(err)
}
err = httpsrv.Serve(ln)
err = httpsrv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
log.Fatalf("derper: %v", err)
@@ -375,6 +335,17 @@ func isChallengeChar(c rune) bool {
c == '.' || c == '-' || c == '_'
}
// probeHandler is the endpoint that js/wasm clients hit to measure
// DERP latency, since they can't do UDP STUN queries.
func probeHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "HEAD", "GET":
w.Header().Set("Access-Control-Allow-Origin", "*")
default:
http.Error(w, "bogus probe method", http.StatusMethodNotAllowed)
}
}
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
func prodAutocertHostPolicy(_ context.Context, host string) error {
@@ -397,8 +368,8 @@ func defaultMeshPSKFile() string {
return ""
}
func rateLimitedListenAndServeTLS(srv *http.Server, lc *net.ListenConfig) error {
ln, err := lc.Listen(context.Background(), "tcp", cmp.Or(srv.Addr, ":https"))
func rateLimitedListenAndServeTLS(srv *http.Server) error {
ln, err := net.Listen("tcp", cmpx.Or(srv.Addr, ":https"))
if err != nil {
return err
}
@@ -452,3 +423,22 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
l.numAccepts.Add(1)
return cn, nil
}
// logFilter is used to filter out useless error logs that are logged to
// the net/http.Server.ErrorLog logger.
type logFilter struct{}
func (logFilter) Write(p []byte) (int, error) {
b := mem.B(p)
if mem.HasSuffix(b, mem.S(": EOF\n")) ||
mem.HasSuffix(b, mem.S(": i/o timeout\n")) ||
mem.HasSuffix(b, mem.S(": read: connection reset by peer\n")) ||
mem.HasSuffix(b, mem.S(": remote error: tls: bad certificate\n")) ||
mem.HasSuffix(b, mem.S(": tls: first record does not look like a TLS handshake\n")) {
// Skip this log message, but say that we processed it
return len(p), nil
}
log.Printf("%s", p)
return len(p), nil
}

View File

@@ -99,13 +99,10 @@ func TestNoContent(t *testing.T) {
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
"testing": "do not use testing package in production code",
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
"tailscale.com/net/packet": "not needed in derper",
"github.com/gaissmai/bart": "not needed in derper",
},
}.Check(t)
}

View File

@@ -15,7 +15,6 @@ import (
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/netmon"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -37,8 +36,7 @@ func startMesh(s *derp.Server) error {
func startMeshWithHost(s *derp.Server, host string) error {
logf := logger.WithPrefix(log.Printf, fmt.Sprintf("mesh(%q): ", host))
netMon := netmon.NewStatic() // good enough for cmd/derper; no need for netns fanciness
c, err := derphttp.NewClient(s.PrivateKey(), "https://"+host+"/derp", logf, netMon)
c, err := derphttp.NewClient(s.PrivateKey(), "https://"+host+"/derp", logf)
if err != nil {
return err
}

View File

@@ -16,40 +16,21 @@ import (
"tailscale.com/prober"
"tailscale.com/tsweb"
"tailscale.com/version"
)
var (
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://) or 'local' to use the local tailscaled's DERP map")
versionFlag = flag.Bool("version", false, "print version and exit")
listen = flag.String("listen", ":8030", "HTTP listen address")
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
spread = flag.Bool("spread", true, "whether to spread probing over time")
interval = flag.Duration("interval", 15*time.Second, "probe interval")
meshInterval = flag.Duration("mesh-interval", 15*time.Second, "mesh probe interval")
stunInterval = flag.Duration("stun-interval", 15*time.Second, "STUN probe interval")
tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval")
bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)")
bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size")
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
listen = flag.String("listen", ":8030", "HTTP listen address")
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
spread = flag.Bool("spread", true, "whether to spread probing over time")
interval = flag.Duration("interval", 15*time.Second, "probe interval")
)
func main() {
flag.Parse()
if *versionFlag {
fmt.Println(version.Long())
return
}
p := prober.New().WithSpread(*spread).WithOnce(*probeOnce).WithMetricNamespace("derpprobe")
opts := []prober.DERPOpt{
prober.WithMeshProbing(*meshInterval),
prober.WithSTUNProbing(*stunInterval),
prober.WithTLSProbing(*tlsInterval),
}
if *bwInterval > 0 {
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
}
dp, err := prober.DERP(p, *derpMapURL, opts...)
dp, err := prober.DERP(p, *derpMapURL, *interval, *interval, *interval)
if err != nil {
log.Fatal(err)
}
@@ -72,7 +53,6 @@ func main() {
mux := http.NewServeMux()
tsweb.Debugger(mux)
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
log.Printf("Listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, mux))
}

16
cmd/dist/dist.go vendored
View File

@@ -13,16 +13,11 @@ import (
"tailscale.com/release/dist"
"tailscale.com/release/dist/cli"
"tailscale.com/release/dist/qnap"
"tailscale.com/release/dist/synology"
"tailscale.com/release/dist/unixpkgs"
)
var (
synologyPackageCenter bool
qnapPrivateKeyPath string
qnapCertificatePath string
)
var synologyPackageCenter bool
func getTargets() ([]dist.Target, error) {
var ret []dist.Target
@@ -38,14 +33,7 @@ func getTargets() ([]dist.Target, error) {
// Since only we can provide packages to Synology for
// distribution, we default to building the "sideload" variant of
// packages that we distribute on pkgs.tailscale.com.
//
// To build for package center, run
// ./tool/go run ./cmd/dist build --synology-package-center synology
ret = append(ret, synology.Targets(synologyPackageCenter, nil)...)
if (qnapPrivateKeyPath == "") != (qnapCertificatePath == "") {
return nil, errors.New("both --qnap-private-key-path and --qnap-certificate-path must be set")
}
ret = append(ret, qnap.Targets(qnapPrivateKeyPath, qnapCertificatePath)...)
return ret, nil
}
@@ -54,8 +42,6 @@ func main() {
for _, subcmd := range cmd.Subcommands {
if subcmd.Name == "build" {
subcmd.FlagSet.BoolVar(&synologyPackageCenter, "synology-package-center", false, "build synology packages with extra metadata for the official package center")
subcmd.FlagSet.StringVar(&qnapPrivateKeyPath, "qnap-private-key-path", "", "sign qnap packages with given key (must also provide --qnap-certificate-path)")
subcmd.FlagSet.StringVar(&qnapCertificatePath, "qnap-certificate-path", "", "sign qnap packages with given certificate (must also provide --qnap-private-key-path)")
}
}

View File

@@ -7,7 +7,6 @@
package main
import (
"cmp"
"context"
"flag"
"fmt"
@@ -17,6 +16,7 @@ import (
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/util/cmpx"
)
func main() {
@@ -40,7 +40,7 @@ func main() {
log.Fatal("at least one tag must be specified")
}
baseURL := cmp.Or(os.Getenv("TS_BASE_URL"), "https://api.tailscale.com")
baseURL := cmpx.Or(os.Getenv("TS_BASE_URL"), "https://api.tailscale.com")
credentials := clientcredentials.Config{
ClientID: clientID,

View File

@@ -158,13 +158,11 @@ func main() {
if !ok && (!oiok || !osok) {
log.Fatal("set envvar TS_API_KEY to your Tailscale API key or TS_OAUTH_ID and TS_OAUTH_SECRET to your Tailscale OAuth ID and Secret")
}
if apiKey != "" && (oauthId != "" || oauthSecret != "") {
if ok && (oiok || osok) {
log.Fatal("set either the envvar TS_API_KEY or TS_OAUTH_ID and TS_OAUTH_SECRET")
}
var client *http.Client
if oiok && (oauthId != "" || oauthSecret != "") {
// Both should ideally be set, but if either are non-empty it means the user had an intent
// to set _something_, so they should receive the oauth error flow.
if oiok {
oauthConfig := &clientcredentials.Config{
ClientID: oauthId,
ClientSecret: oauthSecret,

View File

@@ -31,12 +31,10 @@ var (
//go:embed hello.tmpl.html
var embeddedTemplate string
var localClient tailscale.LocalClient
func main() {
flag.Parse()
if *testIP != "" {
res, err := localClient.WhoIs(context.Background(), *testIP)
res, err := tailscale.WhoIs(context.Background(), *testIP)
if err != nil {
log.Fatal(err)
}
@@ -78,7 +76,7 @@ func main() {
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
switch hi.ServerName {
case "hello.ts.net":
return localClient.GetCertificate(hi)
return tailscale.GetCertificate(hi)
case "hello.ipn.dev":
c, err := tls.LoadX509KeyPair(
"/etc/hello/hello.ipn.dev.crt",
@@ -172,7 +170,7 @@ func root(w http.ResponseWriter, r *http.Request) {
return
}
who, err := localClient.WhoIs(r.Context(), r.RemoteAddr)
who, err := tailscale.WhoIs(r.Context(), r.RemoteAddr)
var data tmplData
if err != nil {
if devMode() {

View File

@@ -430,8 +430,6 @@
<footer class="footer text-gray-600 text-center mb-12">
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
target="_blank">what you can do next &rarr;</a></p>
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
target="_blank">the Hello service &rarr;</a></p>
</footer>
</main>
</body>

View File

@@ -1,379 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
// k8s-nameserver is a simple nameserver implementation meant to be used with
// k8s-operator to allow to resolve magicDNS names associated with tailnet
// proxies in cluster.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"github.com/fsnotify/fsnotify"
"github.com/miekg/dns"
operatorutils "tailscale.com/k8s-operator"
"tailscale.com/util/dnsname"
)
const (
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
tsNetDomain = "ts.net"
// addr is the the address that the UDP and TCP listeners will listen on.
addr = ":1053"
// The following constants are specific to the nameserver configuration
// provided by a mounted Kubernetes Configmap. The Configmap mounted at
// /config is the only supported way for configuring this nameserver.
defaultDNSConfigDir = "/config"
kubeletMountedConfigLn = "..data"
)
// nameserver is a simple nameserver that responds to DNS queries for A records
// for ts.net domain names over UDP or TCP. It serves DNS responses from
// in-memory IPv4 host records. It is intended to be deployed on Kubernetes with
// a ConfigMap mounted at /config that should contain the host records. It
// dynamically reconfigures its in-memory mappings as the contents of the
// mounted ConfigMap changes.
type nameserver struct {
// configReader returns the latest desired configuration (host records)
// for the nameserver. By default it gets set to a reader that reads
// from a Kubernetes ConfigMap mounted at /config, but this can be
// overridden in tests.
configReader configReaderFunc
// configWatcher is a watcher that returns an event when the desired
// configuration has changed and the nameserver should update the
// in-memory records.
configWatcher <-chan string
mu sync.Mutex // protects following
// ip4 are the in-memory hostname -> IP4 mappings that the nameserver
// uses to respond to A record queries.
ip4 map[dnsname.FQDN][]net.IP
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
// Ensure that we watch the kube Configmap mounted at /config for
// nameserver configuration updates and send events when updates happen.
c := ensureWatcherForKubeConfigMap(ctx)
ns := &nameserver{
configReader: configMapConfigReader,
configWatcher: c,
}
// Ensure that in-memory records get set up to date now and will get
// reset when the configuration changes.
ns.runRecordsReconciler(ctx)
// Register a DNS server handle for ts.net domain names. Not having a
// handle registered for any other domain names is how we enforce that
// this nameserver can only be used for ts.net domains - querying any
// other domain names returns Rcode Refused.
dns.HandleFunc(tsNetDomain, ns.handleFunc())
// Listen for DNS queries over UDP and TCP.
udpSig := make(chan os.Signal)
tcpSig := make(chan os.Signal)
go listenAndServe("udp", addr, udpSig)
go listenAndServe("tcp", addr, tcpSig)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
s := <-sig
log.Printf("OS signal (%s) received, shutting down", s)
cancel() // exit the records reconciler and configmap watcher goroutines
udpSig <- s // stop the UDP listener
tcpSig <- s // stop the TCP listener
}
// handleFunc is a DNS query handler that can respond to A record queries from
// the nameserver's in-memory records.
// - If an A record query is received and the
// nameserver's in-memory records contain records for the queried domain name,
// return a success response.
// - If an A record query is received, but the
// nameserver's in-memory records do not contain records for the queried domain name,
// return NXDOMAIN.
// - If an A record query is received, but the queried domain name is not valid, return Format Error.
// - If a query is received for any other record type than A, return Not Implemented.
func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
h := func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
defer func() {
w.WriteMsg(m)
}()
if len(r.Question) < 1 {
log.Print("[unexpected] nameserver received a request with no questions")
m = r.SetRcodeFormatError(r)
return
}
// TODO (irbekrm): maybe set message compression
switch r.Question[0].Qtype {
case dns.TypeA:
q := r.Question[0].Name
fqdn, err := dnsname.ToFQDN(q)
if err != nil {
m = r.SetRcodeFormatError(r)
return
}
// The only supported use of this nameserver is as a
// single source of truth for MagicDNS names by
// non-tailnet Kubernetes workloads.
m.Authoritative = true
m.RecursionAvailable = false
ips := n.lookupIP4(fqdn)
if ips == nil || len(ips) == 0 {
// As we are the authoritative nameserver for MagicDNS
// names, if we do not have a record for this MagicDNS
// name, it does not exist.
m = m.SetRcode(r, dns.RcodeNameError)
return
}
// TODO (irbekrm): TTL is currently set to 0, meaning
// that cluster workloads will not cache the DNS
// records. Revisit this in future when we understand
// the usage patterns better- is it putting too much
// load on kube DNS server or is this fine?
for _, ip := range ips {
rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip}
m.SetRcode(r, dns.RcodeSuccess)
m.Answer = append(m.Answer, rr)
}
case dns.TypeAAAA:
// TODO (irbekrm): add IPv6 support.
// The nameserver currently does not support IPv6
// (records are not being created for IPv6 Pod addresses).
// However, we can expect that some callers will
// nevertheless send AAAA queries.
// We have to return NOERROR if a query is received for
// an AAAA record for a DNS name that we have an A
// record for- else the caller might not follow with an
// A record query.
// https://github.com/tailscale/tailscale/issues/12321
// https://datatracker.ietf.org/doc/html/rfc4074
q := r.Question[0].Name
fqdn, err := dnsname.ToFQDN(q)
if err != nil {
m = r.SetRcodeFormatError(r)
return
}
// The only supported use of this nameserver is as a
// single source of truth for MagicDNS names by
// non-tailnet Kubernetes workloads.
m.Authoritative = true
ips := n.lookupIP4(fqdn)
if len(ips) == 0 {
// As we are the authoritative nameserver for MagicDNS
// names, if we do not have a record for this MagicDNS
// name, it does not exist.
m = m.SetRcode(r, dns.RcodeNameError)
return
}
m.SetRcode(r, dns.RcodeSuccess)
default:
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String())
m.SetRcode(r, dns.RcodeNotImplemented)
}
}
return h
}
// runRecordsReconciler ensures that nameserver's in-memory records are
// reset when the provided configuration changes.
func (n *nameserver) runRecordsReconciler(ctx context.Context) {
log.Print("updating nameserver's records from the provided configuration...")
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
log.Fatalf("error setting nameserver's records: %v", err)
}
log.Print("nameserver's records were updated")
go func() {
for {
select {
case <-ctx.Done():
log.Printf("context cancelled, exiting records reconciler")
return
case <-n.configWatcher:
log.Print("configuration update detected, resetting records")
if err := n.resetRecords(); err != nil {
// TODO (irbekrm): this runs in a
// container that will be thrown away,
// so this should be ok. But maybe still
// need to ensure that the DNS server
// terminates connections more
// gracefully.
log.Fatalf("error resetting records: %v", err)
}
log.Print("nameserver records were reset")
}
}
}()
}
// resetRecords sets the in-memory DNS records of this nameserver from the
// provided configuration. It does not check for the diff, so the caller is
// expected to ensure that this is only called when reset is needed.
func (n *nameserver) resetRecords() error {
dnsCfgBytes, err := n.configReader()
if err != nil {
log.Printf("error reading nameserver's configuration: %v", err)
return err
}
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
log.Print("nameserver's configuration is empty, any in-memory records will be unset")
n.mu.Lock()
n.ip4 = make(map[dnsname.FQDN][]net.IP)
n.mu.Unlock()
return nil
}
dnsCfg := &operatorutils.Records{}
err = json.Unmarshal(dnsCfgBytes, dnsCfg)
if err != nil {
return fmt.Errorf("error unmarshalling nameserver configuration: %v\n", err)
}
if dnsCfg.Version != operatorutils.Alpha1Version {
return fmt.Errorf("unsupported configuration version %s, supported versions are %s\n", dnsCfg.Version, operatorutils.Alpha1Version)
}
ip4 := make(map[dnsname.FQDN][]net.IP)
defer func() {
n.mu.Lock()
defer n.mu.Unlock()
n.ip4 = ip4
}()
if len(dnsCfg.IP4) == 0 {
log.Print("nameserver's configuration contains no records, any in-memory records will be unset")
return nil
}
for fqdn, ips := range dnsCfg.IP4 {
fqdn, err := dnsname.ToFQDN(fqdn)
if err != nil {
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err)
continue // one invalid hostname should not break the whole nameserver
}
for _, ipS := range ips {
ip := net.ParseIP(ipS).To4()
if ip == nil { // To4 returns nil if IP is not a IPv4 address
log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record", ipS)
continue // one invalid IP address should not break the whole nameserver
}
ip4[fqdn] = []net.IP{ip}
}
}
return nil
}
// listenAndServe starts a DNS server for the provided network and address.
func listenAndServe(net, addr string, shutdown chan os.Signal) {
s := &dns.Server{Addr: addr, Net: net}
go func() {
<-shutdown
log.Printf("shutting down server for %s", net)
s.Shutdown()
}()
log.Printf("listening for %s queries on %s", net, addr)
if err := s.ListenAndServe(); err != nil {
log.Fatalf("error running %s server: %v", net, err)
}
}
// ensureWatcherForKubeConfigMap sets up a new file watcher for the ConfigMap
// that's expected to be mounted at /config. Returns a channel that receives an
// event every time the contents get updated.
func ensureWatcherForKubeConfigMap(ctx context.Context) chan string {
c := make(chan string)
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("error creating a new watcher for the mounted ConfigMap: %v", err)
}
// kubelet mounts configmap to a Pod using a series of symlinks, one of
// which is <mount-dir>/..data that Kubernetes recommends consumers to
// use if they need to monitor changes
// https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61
toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn)
go func() {
defer watcher.Close()
log.Printf("starting file watch for %s", defaultDNSConfigDir)
for {
select {
case <-ctx.Done():
log.Print("context cancelled, exiting ConfigMap watcher")
return
case event, ok := <-watcher.Events:
if !ok {
log.Fatal("watcher finished; exiting")
}
if event.Name == toWatch {
msg := fmt.Sprintf("ConfigMap update received: %s", event)
log.Print(msg)
c <- msg
}
case err, ok := <-watcher.Errors:
if err != nil {
// TODO (irbekrm): this runs in a
// container that will be thrown away,
// so this should be ok. But maybe still
// need to ensure that the DNS server
// terminates connections more
// gracefully.
log.Fatalf("[unexpected] error watching configuration: %v", err)
}
if !ok {
// TODO (irbekrm): this runs in a
// container that will be thrown away,
// so this should be ok. But maybe still
// need to ensure that the DNS server
// terminates connections more
// gracefully.
log.Fatalf("[unexpected] errors watcher exited")
}
}
}
}()
if err = watcher.Add(defaultDNSConfigDir); err != nil {
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v", err)
}
return c
}
// configReaderFunc is a function that returns the desired nameserver configuration.
type configReaderFunc func() ([]byte, error)
// configMapConfigReader reads the desired nameserver configuration from a
// records.json file in a ConfigMap mounted at /config.
var configMapConfigReader configReaderFunc = func() ([]byte, error) {
if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, operatorutils.DNSRecordsCMKey)); err == nil {
return contents, nil
} else if os.IsNotExist(err) {
return nil, nil
} else {
return nil, err
}
}
// lookupIP4 returns any IPv4 addresses for the given FQDN from nameserver's
// in-memory records.
func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP {
if n.ip4 == nil {
return nil
}
n.mu.Lock()
defer n.mu.Unlock()
f := n.ip4[fqdn]
return f
}

View File

@@ -1,229 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"net"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/miekg/dns"
"tailscale.com/util/dnsname"
)
func TestNameserver(t *testing.T) {
tests := []struct {
name string
ip4 map[dnsname.FQDN][]net.IP
query *dns.Msg
wantResp *dns.Msg
}{
{
name: "A record query, record exists",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}},
MsgHdr: dns.MsgHdr{Id: 1, RecursionDesired: true},
},
wantResp: &dns.Msg{
Answer: []dns.RR{&dns.A{Hdr: dns.RR_Header{
Name: "foo.bar.com", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0},
A: net.IP{1, 2, 3, 4}}},
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}},
MsgHdr: dns.MsgHdr{
Id: 1,
Rcode: dns.RcodeSuccess,
RecursionAvailable: false,
RecursionDesired: true,
Response: true,
Opcode: dns.OpcodeQuery,
Authoritative: true,
}},
},
{
name: "A record query, record does not exist",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}},
MsgHdr: dns.MsgHdr{Id: 1},
},
wantResp: &dns.Msg{
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}},
MsgHdr: dns.MsgHdr{
Id: 1,
Rcode: dns.RcodeNameError,
RecursionAvailable: false,
Response: true,
Opcode: dns.OpcodeQuery,
Authoritative: true,
}},
},
{
name: "A record query, but the name is not a valid FQDN",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}},
MsgHdr: dns.MsgHdr{Id: 1},
},
wantResp: &dns.Msg{
Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}},
MsgHdr: dns.MsgHdr{
Id: 1,
Rcode: dns.RcodeFormatError,
Response: true,
Opcode: dns.OpcodeQuery,
}},
},
{
name: "AAAA record query, A record exists",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
MsgHdr: dns.MsgHdr{Id: 1},
},
wantResp: &dns.Msg{
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
MsgHdr: dns.MsgHdr{
Id: 1,
Rcode: dns.RcodeSuccess,
Response: true,
Opcode: dns.OpcodeQuery,
Authoritative: true,
}},
},
{
name: "AAAA record query, A record does not exist",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}},
MsgHdr: dns.MsgHdr{Id: 1},
},
wantResp: &dns.Msg{
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}},
MsgHdr: dns.MsgHdr{
Id: 1,
Rcode: dns.RcodeNameError,
Response: true,
Opcode: dns.OpcodeQuery,
Authoritative: true,
}},
},
{
name: "CNAME record query",
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
query: &dns.Msg{
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}},
MsgHdr: dns.MsgHdr{Id: 1},
},
wantResp: &dns.Msg{
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}},
MsgHdr: dns.MsgHdr{
Id: 1,
Rcode: dns.RcodeNotImplemented,
Response: true,
Opcode: dns.OpcodeQuery,
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := &nameserver{
ip4: tt.ip4,
}
handler := ns.handleFunc()
fakeRespW := &fakeResponseWriter{}
handler(fakeRespW, tt.query)
if diff := cmp.Diff(*fakeRespW.msg, *tt.wantResp); diff != "" {
t.Fatalf("unexpected response (-got +want): \n%s", diff)
}
})
}
}
func TestResetRecords(t *testing.T) {
tests := []struct {
name string
config []byte
hasIp4 map[dnsname.FQDN][]net.IP
wantsIp4 map[dnsname.FQDN][]net.IP
wantsErr bool
}{
{
name: "previously empty nameserver.ip4 gets set",
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
},
{
name: "nameserver.ip4 gets reset",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
},
{
name: "configuration with incompatible version",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1beta1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
wantsErr: true,
},
{
name: "nameserver.ip4 gets reset to empty config when no configuration is provided",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
wantsIp4: make(map[dnsname.FQDN][]net.IP),
},
{
name: "nameserver.ip4 gets reset to empty config when the provided configuration is empty",
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
config: []byte(`{"version": "v1alpha1", "ip4": {}}`),
wantsIp4: make(map[dnsname.FQDN][]net.IP),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := &nameserver{
ip4: tt.hasIp4,
configReader: func() ([]byte, error) { return tt.config, nil },
}
if err := ns.resetRecords(); err == nil == tt.wantsErr {
t.Errorf("resetRecords() returned err: %v, wantsErr: %v", err, tt.wantsErr)
}
if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" {
t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", diff)
}
})
}
}
// fakeResponseWriter is a faked out dns.ResponseWriter that can be used in
// tests that need to read the response message that was written.
type fakeResponseWriter struct {
msg *dns.Msg
}
var _ dns.ResponseWriter = &fakeResponseWriter{}
func (fr *fakeResponseWriter) WriteMsg(msg *dns.Msg) error {
fr.msg = msg
return nil
}
func (fr *fakeResponseWriter) LocalAddr() net.Addr {
return nil
}
func (fr *fakeResponseWriter) RemoteAddr() net.Addr {
return nil
}
func (fr *fakeResponseWriter) Write([]byte) (int, error) {
return 0, nil
}
func (fr *fakeResponseWriter) Close() error {
return nil
}
func (fr *fakeResponseWriter) TsigStatus() error {
return nil
}
func (fr *fakeResponseWriter) TsigTimersOnly(bool) {}
func (fr *fakeResponseWriter) Hijack() {}

View File

@@ -33,8 +33,11 @@ import (
const (
reasonConnectorCreationFailed = "ConnectorCreationFailed"
reasonConnectorCreated = "ConnectorCreated"
reasonConnectorInvalid = "ConnectorInvalid"
reasonConnectorCreated = "ConnectorCreated"
reasonConnectorCleanupFailed = "ConnectorCleanupFailed"
reasonConnectorCleanupInProgress = "ConnectorCleanupInProgress"
reasonConnectorInvalid = "ConnectorInvalid"
messageConnectorCreationFailed = "Failed creating Connector: %v"
messageConnectorInvalid = "Connector is invalid: %v"
@@ -105,7 +108,7 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
}
oldCnStatus := cn.Status.DeepCopy()
setStatus := func(cn *tsapi.Connector, _ tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
setStatus := func(cn *tsapi.Connector, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger)
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
// An error encountered here should get returned by the Reconcile function.
@@ -161,17 +164,6 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
hostname = string(cn.Spec.Hostname)
}
crl := childResourceLabels(cn.Name, a.tsnamespace, "connector")
proxyClass := cn.Spec.ProxyClass
if proxyClass != "" {
if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil {
return fmt.Errorf("error verifying ProxyClass for Connector: %w", err)
} else if !ready {
logger.Infof("ProxyClass %s specified for the Connector, but is not (yet) Ready, waiting..", proxyClass)
return nil
}
}
sts := &tailscaleSTSConfig{
ParentResourceName: cn.Name,
ParentResourceUID: string(cn.UID),
@@ -181,7 +173,6 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
Connector: &connector{
isExitNode: cn.Spec.ExitNode,
},
ProxyClassName: proxyClass,
}
if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.AdvertiseRoutes) > 0 {
@@ -208,27 +199,7 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
gaugeConnectorResources.Set(int64(connectors.Len()))
_, err := a.ssr.Provision(ctx, logger, sts)
if err != nil {
return err
}
_, tsHost, ips, err := a.ssr.DeviceInfo(ctx, crl)
if err != nil {
return err
}
if tsHost == "" {
logger.Debugf("no Tailscale hostname known yet, waiting for connector pod to finish auth")
// No hostname yet. Wait for the connector pod to auth.
cn.Status.TailnetIPs = nil
cn.Status.Hostname = ""
return nil
}
cn.Status.TailnetIPs = ips
cn.Status.Hostname = tsHost
return nil
return err
}
func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) {

View File

@@ -17,7 +17,6 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstest"
"tailscale.com/util/mak"
)
func TestConnector(t *testing.T) {
@@ -30,7 +29,7 @@ func TestConnector(t *testing.T) {
},
TypeMeta: metav1.TypeMeta{
Kind: tsapi.ConnectorKind,
APIVersion: "tailscale.com/v1alpha1",
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
SubnetRouter: &tsapi.SubnetRouter{
@@ -68,57 +67,45 @@ func TestConnector(t *testing.T) {
fullName, shortName := findGenName(t, fc, "", "test", "connector")
opts := configOpts{
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
shouldUseDeclarativeConfig: true,
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// Connector status should get updated with the IP/hostname info when available.
const hostname = "foo.tailnetxyz.ts.net"
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
mak.Set(&secret.Data, "device_id", []byte("1234"))
mak.Set(&secret.Data, "device_fqdn", []byte(hostname))
mak.Set(&secret.Data, "device_ips", []byte(`["127.0.0.1", "::1"]`))
})
expectReconciled(t, cr, "", "test")
cn.Finalizers = append(cn.Finalizers, "tailscale.com/finalizer")
cn.Status.IsExitNode = cn.Spec.ExitNode
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
cn.Status.Hostname = hostname
cn.Status.TailnetIPs = []string{"127.0.0.1", "::1"}
expectEqual(t, fc, cn, func(o *tsapi.Connector) {
o.Status.Conditions = nil
})
expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(opts))
// Add another route to be advertised.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
})
opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20"
opts.confFileHash = "fb6c4daf67425f983985750cd8d6f2beae77e614fcb34176604571f5623d6862"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
expectEqual(t, fc, expectedSTS(opts))
// Remove a route.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"}
})
opts.subnetRoutes = "10.44.0.0/20"
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
expectEqual(t, fc, expectedSTS(opts))
// Remove the subnet router.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter = nil
})
opts.subnetRoutes = ""
opts.confFileHash = "7c421a99128eb80e79a285a82702f19f8f720615542a15bd794858a6275d8079"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
expectEqual(t, fc, expectedSTS(opts))
// Re-add the subnet router.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
@@ -127,8 +114,9 @@ func TestConnector(t *testing.T) {
}
})
opts.subnetRoutes = "10.44.0.0/20"
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
expectEqual(t, fc, expectedSTS(opts))
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
@@ -164,22 +152,25 @@ func TestConnector(t *testing.T) {
fullName, shortName = findGenName(t, fc, "", "test", "connector")
opts = configOpts{
stsName: shortName,
secretName: fullName,
parentType: "connector",
subnetRoutes: "10.40.0.0/14",
hostname: "test-connector",
stsName: shortName,
secretName: fullName,
parentType: "connector",
shouldUseDeclarativeConfig: true,
subnetRoutes: "10.40.0.0/14",
hostname: "test-connector",
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e",
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
expectEqual(t, fc, expectedSecret(t, opts))
expectEqual(t, fc, expectedSTS(opts))
// Add an exit node.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.ExitNode = true
})
opts.isExitNode = true
opts.confFileHash = "1499b591fd97a50f0330db6ec09979792c49890cf31f5da5bb6a3f50dba1e77a"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
expectEqual(t, fc, expectedSTS(opts))
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
@@ -192,103 +183,3 @@ func TestConnector(t *testing.T) {
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
}
func TestConnectorWithProxyClass(t *testing.T) {
// Setup
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
Labels: map[string]string{"foo": "bar"},
Annotations: map[string]string{"bar.io/foo": "some-val"},
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
}
cn := &tsapi.Connector{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
UID: types.UID("1234-UID"),
},
TypeMeta: metav1.TypeMeta{
Kind: tsapi.ConnectorKind,
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
SubnetRouter: &tsapi.SubnetRouter{
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
},
ExitNode: true,
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc, cn).
WithStatusSubresource(pc, cn).
Build()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
cl := tstest.NewClock(tstest.ClockOpts{})
cr := &ConnectorReconciler{
Client: fc,
clock: cl,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// 1. Connector is created with no ProxyClass specified, create
// resources with the default configuration.
expectReconciled(t, cr, "", "test")
fullName, shortName := findGenName(t, fc, "", "test", "connector")
opts := configOpts{
stsName: shortName,
secretName: fullName,
parentType: "connector",
hostname: "test-connector",
isExitNode: true,
subnetRoutes: "10.40.0.0/14",
}
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// 2. Update Connector to specify a ProxyClass. ProxyClass is not yet
// ready, so its configuration is NOT applied to the Connector
// resources.
mustUpdate(t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.ProxyClass = "custom-metadata"
})
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// 3. ProxyClass is set to Ready by proxy-class reconciler. Connector
// get reconciled and configuration from the ProxyClass is applied to
// its resources.
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []metav1.Condition{{
Status: metav1.ConditionTrue,
Type: string(tsapi.ProxyClassready),
ObservedGeneration: pc.Generation,
}}}
})
opts.proxyClass = pc.Name
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// 4. Connector.spec.proxyClass field is unset, Connector gets
// reconciled and configuration from the ProxyClass is removed from the
// cluster resources for the Connector.
mustUpdate(t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.ProxyClass = ""
})
opts.proxyClass = ""
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
}

View File

@@ -21,9 +21,6 @@ spec:
{{- end }}
labels:
app: operator
{{- with .Values.operatorConfig.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
@@ -49,11 +46,9 @@ spec:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $operatorTag:= printf ":%s" ( .Values.operatorConfig.image.tag | default .Chart.AppVersion )}}
image: {{ coalesce .Values.operatorConfig.image.repo .Values.operatorConfig.image.repository }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }}
image: {{ .Values.operatorConfig.image.repo }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }}
imagePullPolicy: {{ .Values.operatorConfig.image.pullPolicy }}
env:
- name: OPERATOR_INITIAL_TAGS
value: {{ join "," .Values.operatorConfig.defaultTags }}
- name: OPERATOR_HOSTNAME
value: {{ .Values.operatorConfig.hostname }}
- name: OPERATOR_SECRET
@@ -64,13 +59,15 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: ENABLE_CONNECTOR
value: "{{ .Values.enableConnector }}"
- name: CLIENT_ID_FILE
value: /oauth/client_id
- name: CLIENT_SECRET_FILE
value: /oauth/client_secret
{{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}}
- name: PROXY_IMAGE
value: {{ coalesce .Values.proxyConfig.image.repo .Values.proxyConfig.image.repository }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
value: {{ .Values.proxyConfig.image.repo }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
- name: PROXY_TAGS
value: {{ .Values.proxyConfig.defaultTags }}
- name: APISERVER_PROXY

View File

@@ -1,8 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: tailscale # class name currently can not be changed
annotations: {} # we do not support default IngressClass annotation https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class
spec:
controller: tailscale.com/ts-ingress # controller name currently can not be changed
# parameters: {} # currently no parameters are supported

View File

@@ -18,14 +18,8 @@ rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "ingresses/status"]
verbs: ["*"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingressclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: ["tailscale.com"]
resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["tailscale.com"]
resources: ["dnsconfigs", "dnsconfigs/status"]
resources: ["connectors", "connectors/status"]
verbs: ["get", "list", "watch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
@@ -48,14 +42,11 @@ metadata:
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets", "serviceaccounts", "configmaps"]
resources: ["secrets"]
verbs: ["*"]
- apiGroups: ["apps"]
resources: ["statefulsets", "deployments"]
resources: ["statefulsets"]
verbs: ["*"]
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

View File

@@ -8,23 +8,19 @@ oauth: {}
# clientId: ""
# clientSecret: ""
# enableConnector determines whether the operator should reconcile
# connector.tailscale.com custom resources.
enableConnector: "false"
# installCRDs determines whether tailscale.com CRDs should be installed as part
# of chart installation. We do not use Helm's CRD installation mechanism as that
# does not allow for upgrading CRDs.
# https://helm.sh/docs/chart_best_practices/custom_resource_definitions/
installCRDs: true
installCRDs: "true"
operatorConfig:
# ACL tag that operator will be tagged with. Operator must be made owner of
# these tags
# https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator
# Multiple tags are defined as array items and passed to the operator as a comma-separated string
defaultTags:
- "tag:k8s-operator"
image:
# Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/k8s-operator.
repository: tailscale/k8s-operator
repo: tailscale/k8s-operator
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
@@ -38,7 +34,6 @@ operatorConfig:
resources: {}
podAnnotations: {}
podLabels: {}
tolerations: []
@@ -52,14 +47,9 @@ operatorConfig:
# proxies created by the operator.
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
# Note that this section contains only a few global configuration options and
# will not be updated with more configuration options in the future.
# If you need more configuration options, take a look at ProxyClass:
# https://tailscale.com/kb/1236/kubernetes-operator#cluster-resource-customization-using-proxyclass-custom-resource
proxyConfig:
image:
# Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/tailscale.
repository: tailscale/tailscale
repo: tailscale/tailscale
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
controller-gen.kubebuilder.io/version: v0.13.0
name: connectors.tailscale.com
spec:
group: tailscale.com
@@ -31,95 +31,44 @@ spec:
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
Connector defines a Tailscale node that will be deployed in the cluster. The
node can be configured to act as a Tailscale subnet router and/or a Tailscale
exit node.
Connector is a cluster-scoped resource.
More info:
https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource
type: object
required:
- spec
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
type: string
metadata:
type: object
spec:
description: |-
ConnectorSpec describes the desired Tailscale component.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
description: ConnectorSpec describes the desired Tailscale component.
type: object
properties:
exitNode:
description: |-
ExitNode defines whether the Connector node should act as a
Tailscale exit node. Defaults to false.
https://tailscale.com/kb/1103/exit-nodes
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
type: boolean
hostname:
description: |-
Hostname is the tailnet hostname that should be assigned to the
Connector node. If unset, hostname defaults to <connector
name>-connector. Hostname can contain lower case letters, numbers and
dashes, it must not start or end with a dash and must be between 2
and 63 characters long.
description: Hostname is the tailnet hostname that should be assigned to the Connector node. If unset, hostname defaults to <connector name>-connector. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
proxyClass:
description: |-
ProxyClass is the name of the ProxyClass custom resource that
contains configuration options that should be applied to the
resources created for this Connector. If unset, the operator will
create resources with the default configuration.
type: string
subnetRouter:
description: |-
SubnetRouter defines subnet routes that the Connector node should
expose to tailnet. If unset, none are exposed.
https://tailscale.com/kb/1019/subnets/
description: SubnetRouter defines subnet routes that the Connector node should expose to tailnet. If unset, none are exposed. https://tailscale.com/kb/1019/subnets/
type: object
required:
- advertiseRoutes
properties:
advertiseRoutes:
description: |-
AdvertiseRoutes refer to CIDRs that the subnet router should make
available. Route values must be strings that represent a valid IPv4
or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes.
https://tailscale.com/kb/1201/4via6-subnets/
description: AdvertiseRoutes refer to CIDRs that the subnet router should make available. Route values must be strings that represent a valid IPv4 or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. https://tailscale.com/kb/1201/4via6-subnets/
type: array
minItems: 1
items:
type: string
format: cidr
tags:
description: |-
Tags that the Tailscale node will be tagged with.
Defaults to [tag:k8s].
To autoapprove the subnet routes or exit node defined by a Connector,
you can configure Tailscale ACLs to give these tags the necessary
permissions.
See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes.
If you specify custom tags here, you must also make the operator an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a Connector node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
description: Tags that the Tailscale node will be tagged with. Defaults to [tag:k8s]. To autoapprove the subnet routes or exit node defined by a Connector, you can configure Tailscale ACLs to give these tags the necessary permissions. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes. If you specify custom tags here, you must also make the operator an owner of these tags. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector node has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
type: array
items:
type: string
@@ -128,93 +77,48 @@ spec:
- rule: has(self.subnetRouter) || self.exitNode == true
message: A Connector needs to be either an exit node or a subnet router, or both.
status:
description: |-
ConnectorStatus describes the status of the Connector. This is set
and managed by the Tailscale operator.
description: ConnectorStatus describes the status of the Connector. This is set and managed by the Tailscale operator.
type: object
properties:
conditions:
description: |-
List of status conditions to indicate the status of the Connector.
Known condition types are `ConnectorReady`.
description: List of status conditions to indicate the status of the Connector. Known condition types are `ConnectorReady`.
type: array
items:
description: Condition contains details for one aspect of the current state of this API Resource.
description: ConnectorCondition contains condition information for a Connector.
type: object
required:
- lastTransitionTime
- message
- reason
- status
- type
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
type: string
format: date-time
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
description: Message is a human readable description of the details of the last transition, complementing reason.
type: string
maxLength: 32768
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
type: integer
format: int64
minimum: 0
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
description: Reason is a brief machine readable explanation for the condition's last transition.
type: string
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
status:
description: status of the condition, one of True, False, Unknown.
description: Status of the condition, one of ('True', 'False', 'Unknown').
type: string
enum:
- "True"
- "False"
- Unknown
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
description: Type of the condition, known values are (`SubnetRouterReady`).
type: string
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
hostname:
description: |-
Hostname is the fully qualified domain name of the Connector node.
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
node.
type: string
isExitNode:
description: IsExitNode is set to true if the Connector acts as an exit node.
type: boolean
subnetRoutes:
description: |-
SubnetRoutes are the routes currently exposed to tailnet via this
Connector instance.
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
type: string
tailnetIPs:
description: |-
TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
assigned to the Connector node.
type: array
items:
type: string
served: true
storage: true
subresources:

View File

@@ -1,181 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: dnsconfigs.tailscale.com
spec:
group: tailscale.com
names:
kind: DNSConfig
listKind: DNSConfigList
plural: dnsconfigs
shortNames:
- dc
singular: dnsconfig
scope: Cluster
versions:
- additionalPrinterColumns:
- description: Service IP address of the nameserver
jsonPath: .status.nameserver.ip
name: NameserverIP
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS
names resolvable by cluster workloads. Use this if: A) you need to refer to
tailnet services, exposed to cluster via Tailscale Kubernetes operator egress
proxies by the MagicDNS names of those tailnet services (usually because the
services run over HTTPS)
B) you have exposed a cluster workload to the tailnet using Tailscale Ingress
and you also want to refer to the workload from within the cluster over the
Ingress's MagicDNS name (usually because you have some callback component
that needs to use the same URL as that used by a non-cluster client on
tailnet).
When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will
deploy a nameserver for ts.net DNS names and automatically populate it with records
for any Tailscale egress or Ingress proxies deployed to that cluster.
Currently you must manually update your cluster DNS configuration to add the
IP address of the deployed nameserver as a ts.net stub nameserver.
Instructions for how to do it:
https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS),
https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns).
Tailscale Kubernetes operator will write the address of a Service fronting
the nameserver to dsnconfig.status.nameserver.ip.
DNSConfig is a singleton - you must not create more than one.
NB: if you want cluster workloads to be able to refer to Tailscale Ingress
using its MagicDNS name, you must also annotate the Ingress resource with
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
ensure that the proxy created for the Ingress listens on its Pod IP address.
NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.
type: object
required:
- spec
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
Spec describes the desired DNS configuration.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
type: object
required:
- nameserver
properties:
nameserver:
description: |-
Configuration for a nameserver that can resolve ts.net DNS names
associated with in-cluster proxies for Tailscale egress Services and
Tailscale Ingresses. The operator will always deploy this nameserver
when a DNSConfig is applied.
type: object
properties:
image:
description: Nameserver image.
type: object
properties:
repo:
description: Repo defaults to tailscale/k8s-nameserver.
type: string
tag:
description: Tag defaults to operator's own tag.
type: string
status:
description: |-
Status describes the status of the DNSConfig. This is set
and managed by the Tailscale operator.
type: object
properties:
conditions:
type: array
items:
description: Condition contains details for one aspect of the current state of this API Resource.
type: object
required:
- lastTransitionTime
- message
- reason
- status
- type
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
type: string
format: date-time
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
type: string
maxLength: 32768
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
type: integer
format: int64
minimum: 0
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
type: string
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
status:
description: status of the condition, one of True, False, Unknown.
type: string
enum:
- "True"
- "False"
- Unknown
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
nameserver:
description: Nameserver describes the status of nameserver cluster resources.
type: object
properties:
ip:
description: |-
IP is the ClusterIP of the Service fronting the deployed ts.net nameserver.
Currently you must manually update your cluster DNS config to add
this address as a stub nameserver for ts.net for cluster workloads to be
able to resolve MagicDNS names associated with egress or Ingress
proxies.
The IP address will change if you delete and recreate the DNSConfig.
type: string
served: true
storage: true
subresources:
status: {}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
apiVersion: tailscale.com/v1alpha1
kind: DNSConfig
metadata:
name: ts-dns
spec:
nameserver: {}

View File

@@ -1,23 +0,0 @@
apiVersion: tailscale.com/v1alpha1
kind: ProxyClass
metadata:
name: prod
spec:
metrics:
enable: true
statefulSet:
annotations:
platform-component: infra
pod:
labels:
team: eng
nodeSelector:
kubernetes.io/os: "linux"
imagePullSecrets:
- name: "foo"
tailscaleContainer:
image: "ghcr.io/tailscale/tailscale:v1.64"
imagePullPolicy: IfNotPresent
tailscaleInitContainer:
image: "ghcr.io/tailscale/tailscale:v1.64"
imagePullPolicy: IfNotPresent

View File

@@ -1,4 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: dnsrecords

View File

@@ -1,37 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: nameserver
spec:
replicas: 1
revisionHistoryLimit: 5
selector:
matchLabels:
app: nameserver
strategy:
type: Recreate
template:
metadata:
labels:
app: nameserver
spec:
containers:
- imagePullPolicy: IfNotPresent
name: nameserver
ports:
- name: tcp
protocol: TCP
containerPort: 1053
- name: udp
protocol: UDP
containerPort: 1053
volumeMounts:
- name: dnsrecords
mountPath: /config
restartPolicy: Always
serviceAccount: nameserver
serviceAccountName: nameserver
volumes:
- name: dnsrecords
configMap:
name: dnsrecords

View File

@@ -1,4 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: nameserver

View File

@@ -1,16 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: nameserver
spec:
selector:
app: nameserver
ports:
- name: udp
targetPort: 1053
port: 53
protocol: UDP
- name: tcp
targetPort: 1053
port: 53
protocol: TCP

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,10 @@ spec:
- name: sysctler
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args: [sysctl -w net.ipv4.ip_forward=1 && if sysctl net.ipv6.conf.all.forwarding; then sysctl -w net.ipv6.conf.all.forwarding=1; fi]
command: ["/bin/sh"]
args:
- -c
- sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1
resources:
requests:
cpu: 1m
@@ -26,10 +28,8 @@ spec:
env:
- name: TS_USERSPACE
value: "false"
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: TS_AUTH_ONCE
value: "true"
securityContext:
capabilities:
add:

View File

@@ -20,7 +20,5 @@ spec:
env:
- name: TS_USERSPACE
value: "true"
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: TS_AUTH_ONCE
value: "true"

View File

@@ -1,337 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
// tailscale-operator provides a way to expose services running in a Kubernetes
// cluster to your Tailnet and to make Tailscale nodes available to cluster
// workloads
package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
networkingv1 "k8s.io/api/networking/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/net"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/util/mak"
)
const (
dnsRecordsRecocilerFinalizer = "tailscale.com/dns-records-reconciler"
annotationTSMagicDNSName = "tailscale.com/magic-dnsname"
)
// dnsRecordsReconciler knows how to update dnsrecords ConfigMap with DNS
// records.
// The records that it creates are:
// - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP address of
// the ingress proxy Pod.
// - For egress proxies configured via tailscale.com/tailnet-fqdn annotation, a
// mapping of the tailnet FQDN to the IP address of the egress proxy Pod.
//
// Records will only be created if there is exactly one ready
// tailscale.com/v1alpha1.DNSConfig instance in the cluster (so that we know
// that there is a ts.net nameserver deployed in the cluster).
type dnsRecordsReconciler struct {
client.Client
tsNamespace string // namespace in which we provision tailscale resources
logger *zap.SugaredLogger
isDefaultLoadBalancer bool // true if operator is the default ingress controller in this cluster
}
// Reconcile takes a reconcile.Request for a headless Service fronting a
// tailscale proxy and updates DNS Records in dnsrecords ConfigMap for the
// in-cluster ts.net nameserver if required.
func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := dnsRR.logger.With("Service", req.NamespacedName)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
headlessSvc := new(corev1.Service)
err = dnsRR.Client.Get(ctx, req.NamespacedName, headlessSvc)
if apierrors.IsNotFound(err) {
logger.Debugf("Service not found")
return reconcile.Result{}, nil
}
if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get Service: %w", err)
}
if !(isManagedByType(headlessSvc, "svc") || isManagedByType(headlessSvc, "ingress")) {
logger.Debugf("Service is not a headless Service for a tailscale ingress or egress proxy; do nothing")
return reconcile.Result{}, nil
}
if !headlessSvc.DeletionTimestamp.IsZero() {
logger.Debug("Service is being deleted, clean up resources")
return reconcile.Result{}, dnsRR.maybeCleanup(ctx, headlessSvc, logger)
}
// Check that there is a ts.net nameserver deployed to the cluster by
// checking that there is tailscale.com/v1alpha1.DNSConfig resource in a
// Ready state.
dnsCfgLst := new(tsapi.DNSConfigList)
if err = dnsRR.List(ctx, dnsCfgLst); err != nil {
return reconcile.Result{}, fmt.Errorf("error listing DNSConfigs: %w", err)
}
if len(dnsCfgLst.Items) == 0 {
logger.Debugf("DNSConfig does not exist, not creating DNS records")
return reconcile.Result{}, nil
}
if len(dnsCfgLst.Items) > 1 {
logger.Errorf("Invalid cluster state - more than one DNSConfig found in cluster. Please ensure no more than one exists")
return reconcile.Result{}, nil
}
dnsCfg := dnsCfgLst.Items[0]
if !operatorutils.DNSCfgIsReady(&dnsCfg) {
logger.Info("DNSConfig is not ready yet, waiting...")
return reconcile.Result{}, nil
}
return reconcile.Result{}, dnsRR.maybeProvision(ctx, headlessSvc, logger)
}
// maybeProvision ensures that dnsrecords ConfigMap contains a record for the
// proxy associated with the headless Service.
// The record is only provisioned if the proxy is for a tailscale Ingress or
// egress configured via tailscale.com/tailnet-fqdn annotation.
//
// For Ingress, the record is a mapping between the MagicDNSName of the Ingress, retrieved from
// ingress.status.loadBalancer.ingress.hostname field and the proxy Pod IP addresses
// retrieved from the EndpoinSlice associated with this headless Service, i.e
// Records{IP4: <MagicDNS name of the Ingress>: <[IPs of the ingress proxy Pods]>}
//
// For egress, the record is a mapping between tailscale.com/tailnet-fqdn
// annotation and the proxy Pod IP addresses, retrieved from the EndpointSlice
// associated with this headless Service, i.e
// Records{IP4: {<tailscale.com/tailnet-fqdn>: <[IPs of the egress proxy Pods]>}
//
// If records need to be created for this proxy, maybeProvision will also:
// - update the headless Service with a tailscale.com/magic-dnsname annotation
// - update the headless Service with a finalizer
func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) error {
if headlessSvc == nil {
logger.Info("[unexpected] maybeProvision called with a nil Service")
return nil
}
isEgressFQDNSvc, err := dnsRR.isSvcForFQDNEgressProxy(ctx, headlessSvc)
if err != nil {
return fmt.Errorf("error checking whether the Service is for an egress proxy: %w", err)
}
if !(isEgressFQDNSvc || isManagedByType(headlessSvc, "ingress")) {
logger.Debug("Service is not fronting a proxy that we create DNS records for; do nothing")
return nil
}
fqdn, err := dnsRR.fqdnForDNSRecord(ctx, headlessSvc, logger)
if err != nil {
return fmt.Errorf("error determining DNS name for record: %w", err)
}
if fqdn == "" {
logger.Debugf("MagicDNS name does not (yet) exist, not provisioning DNS record")
return nil // a new reconcile will be triggered once it's added
}
oldHeadlessSvc := headlessSvc.DeepCopy()
// Ensure that headless Service is annotated with a finalizer to help
// with records cleanup when proxy resources are deleted.
if !slices.Contains(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer) {
headlessSvc.Finalizers = append(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer)
}
// Ensure that headless Service is annotated with the current MagicDNS
// name to help with records cleanup when proxy resources are deleted or
// MagicDNS name changes.
oldFqdn := headlessSvc.Annotations[annotationTSMagicDNSName]
if oldFqdn != "" && oldFqdn != fqdn { // i.e user has changed the value of tailscale.com/tailnet-fqdn annotation
logger.Debugf("MagicDNS name has changed, remvoving record for %s", oldFqdn)
updateFunc := func(rec *operatorutils.Records) {
delete(rec.IP4, oldFqdn)
}
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error removing record for %s: %w", oldFqdn, err)
}
}
mak.Set(&headlessSvc.Annotations, annotationTSMagicDNSName, fqdn)
if !apiequality.Semantic.DeepEqual(oldHeadlessSvc, headlessSvc) {
logger.Infof("provisioning DNS record for MagicDNS name: %s", fqdn) // this will be printed exactly once
if err := dnsRR.Update(ctx, headlessSvc); err != nil {
return fmt.Errorf("error updating proxy headless Service metadata: %w", err)
}
}
// Get the Pod IP addresses for the proxy from the EndpointSlice for the
// headless Service.
labels := map[string]string{discoveryv1.LabelServiceName: headlessSvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, dnsRR.Client, dnsRR.tsNamespace, labels)
if err != nil {
return fmt.Errorf("error getting the EndpointSlice for the proxy's headless Service: %w", err)
}
if eps == nil {
logger.Debugf("proxy's headless Service EndpointSlice does not yet exist. We will reconcile again once it's created")
return nil
}
// An EndpointSlice for a Service can have a list of endpoints that each
// can have multiple addresses - these are the IP addresses of any Pods
// selected by that Service. Pick all the IPv4 addresses.
ips := make([]string, 0)
for _, ep := range eps.Endpoints {
for _, ip := range ep.Addresses {
if !net.IsIPv4String(ip) {
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
} else {
ips = append(ips, ip)
}
}
}
if len(ips) == 0 {
logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses. We will reconcile again once they are created.")
return nil
}
updateFunc := func(rec *operatorutils.Records) {
mak.Set(&rec.IP4, fqdn, ips)
}
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS records: %w", err)
}
return nil
}
// maybeCleanup ensures that the DNS record for the proxy has been removed from
// dnsrecords ConfigMap and the tailscale.com/dns-records-reconciler finalizer
// has been removed from the Service. If the record is not found in the
// ConfigMap, the ConfigMap does not exist, or the Service does not have
// tailscale.com/magic-dnsname annotation, just remove the finalizer.
func (h *dnsRecordsReconciler) maybeCleanup(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) error {
ix := slices.Index(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer)
if ix == -1 {
logger.Debugf("no finalizer, nothing to do")
return nil
}
cm := &corev1.ConfigMap{}
err := h.Client.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: h.tsNamespace}, cm)
if apierrors.IsNotFound(err) {
logger.Debug("'dsnrecords' ConfigMap not found")
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
}
if err != nil {
return fmt.Errorf("error retrieving 'dnsrecords' ConfigMap: %w", err)
}
if cm.Data == nil {
logger.Debug("'dnsrecords' ConfigMap contains no records")
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
}
_, ok := cm.Data[operatorutils.DNSRecordsCMKey]
if !ok {
logger.Debug("'dnsrecords' ConfigMap contains no records")
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
}
fqdn, _ := headlessSvc.GetAnnotations()[annotationTSMagicDNSName]
if fqdn == "" {
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
}
logger.Infof("removing DNS record for MagicDNS name %s", fqdn)
updateFunc := func(rec *operatorutils.Records) {
delete(rec.IP4, fqdn)
}
if err = h.updateDNSConfig(ctx, updateFunc); err != nil {
return fmt.Errorf("error updating DNS config: %w", err)
}
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
}
func (dnsRR *dnsRecordsReconciler) removeHeadlessSvcFinalizer(ctx context.Context, headlessSvc *corev1.Service) error {
idx := slices.Index(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer)
if idx == -1 {
return nil
}
headlessSvc.Finalizers = append(headlessSvc.Finalizers[:idx], headlessSvc.Finalizers[idx+1:]...)
return dnsRR.Update(ctx, headlessSvc)
}
// fqdnForDNSRecord returns MagicDNS name associated with a given headless Service.
// If the headless Service is for a tailscale Ingress proxy, returns ingress.status.loadBalancer.ingress.hostname.
// If the headless Service is for an tailscale egress proxy configured via tailscale.com/tailnet-fqdn annotation, returns the annotation value.
// This function is not expected to be called with headless Services for other
// proxy types, or any other Services, but it just returns an empty string if
// that happens.
func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) (string, error) {
parentName := parentFromObjectLabels(headlessSvc)
if isManagedByType(headlessSvc, "ingress") {
ing := new(networkingv1.Ingress)
if err := dnsRR.Get(ctx, parentName, ing); err != nil {
return "", err
}
if len(ing.Status.LoadBalancer.Ingress) == 0 {
return "", nil
}
return ing.Status.LoadBalancer.Ingress[0].Hostname, nil
}
if isManagedByType(headlessSvc, "svc") {
svc := new(corev1.Service)
if err := dnsRR.Get(ctx, parentName, svc); apierrors.IsNotFound(err) {
logger.Info("[unexpected] parent Service for egress proxy %s not found", headlessSvc.Name)
return "", nil
} else if err != nil {
return "", err
}
return svc.Annotations[AnnotationTailnetTargetFQDN], nil
}
return "", nil
}
// updateDNSConfig runs the provided update function against dnsrecords
// ConfigMap. At this point the in-cluster ts.net nameserver is expected to be
// successfully created together with the ConfigMap.
func (dnsRR *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.Records)) error {
cm := &corev1.ConfigMap{}
err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, cm)
if apierrors.IsNotFound(err) {
dnsRR.logger.Info("[unexpected] dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an isue and attach operator logs.")
return nil
}
if err != nil {
return fmt.Errorf("error retrieving dnsrecords ConfigMap: %w", err)
}
dnsRecords := operatorutils.Records{Version: operatorutils.Alpha1Version, IP4: map[string][]string{}}
if cm.Data != nil && cm.Data[operatorutils.DNSRecordsCMKey] != "" {
if err := json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil {
return err
}
}
update(&dnsRecords)
dnsRecordsBs, err := json.Marshal(dnsRecords)
if err != nil {
return fmt.Errorf("error marshalling DNS records: %w", err)
}
mak.Set(&cm.Data, operatorutils.DNSRecordsCMKey, string(dnsRecordsBs))
return dnsRR.Update(ctx, cm)
}
// isSvcForFQDNEgressProxy returns true if the Service is a headless Service
// created for a proxy for a tailscale egress Service configured via
// tailscale.com/tailnet-fqdn annotation.
func (dnsRR *dnsRecordsReconciler) isSvcForFQDNEgressProxy(ctx context.Context, svc *corev1.Service) (bool, error) {
if !isManagedByType(svc, "svc") {
return false, nil
}
parentName := parentFromObjectLabels(svc)
parentSvc := new(corev1.Service)
if err := dnsRR.Get(ctx, parentName, parentSvc); apierrors.IsNotFound(err) {
return false, nil
} else if err != nil {
return false, err
}
annots := parentSvc.Annotations
return annots != nil && annots[AnnotationTailnetTargetFQDN] != "", nil
}

View File

@@ -1,198 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstest"
"tailscale.com/types/ptr"
)
func TestDNSRecordsReconciler(t *testing.T) {
// Preconfigure a cluster with a DNSConfig
dnsConfig := &tsapi.DNSConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"},
Spec: tsapi.DNSConfigSpec{
Nameserver: &tsapi.Nameserver{},
}}
ing := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ts-ingress",
Namespace: "test",
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
},
Status: networkingv1.IngressStatus{
LoadBalancer: networkingv1.IngressLoadBalancerStatus{
Ingress: []networkingv1.IngressLoadBalancerIngress{{
Hostname: "cluster.ingress.ts.net"}},
},
},
}
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", Namespace: "tailscale"}}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(cm).
WithObjects(dnsConfig).
WithObjects(ing).
WithStatusSubresource(dnsConfig, ing).
Build()
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
cl := tstest.NewClock(tstest.ClockOpts{})
// Set the ready condition of the DNSConfig
mustUpdateStatus[tsapi.DNSConfig](t, fc, "", "test", func(c *tsapi.DNSConfig) {
operatorutils.SetDNSConfigCondition(c, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated, 0, cl, zl.Sugar())
})
dnsRR := &dnsRecordsReconciler{
Client: fc,
logger: zl.Sugar(),
tsNamespace: "tailscale",
}
// 1. DNS record is created for an egress proxy configured via
// tailscale.com/tailnet-fqdn annotation
egressSvcFQDN := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "egress-fqdn",
Namespace: "test",
Annotations: map[string]string{"tailscale.com/tailnet-fqdn": "foo.bar.ts.net"},
},
Spec: corev1.ServiceSpec{
ExternalName: "unused",
Type: corev1.ServiceTypeExternalName,
},
}
headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7")
mustCreate(t, fc, egressSvcFQDN)
mustCreate(t, fc, headlessForEgressSvcFQDN)
mustCreate(t, fc, ep)
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
// ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
expectHostsRecords(t, fc, wantHosts)
// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
// value changes
mustUpdate(t, fc, "test", "egress-fqdn", func(svc *corev1.Service) {
svc.Annotations["tailscale.com/tailnet-fqdn"] = "baz.bar.ts.net"
})
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
wantHosts = map[string][]string{"baz.bar.ts.net": {"10.9.8.7"}}
expectHostsRecords(t, fc, wantHosts)
// 3. DNS record is updated if the IP address of the proxy Pod changes.
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4")
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
ep.Endpoints[0].Addresses = []string{"10.6.5.4"}
})
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
wantHosts = map[string][]string{"baz.bar.ts.net": {"10.6.5.4"}}
expectHostsRecords(t, fc, wantHosts)
// 4. DNS record is created for an ingress proxy configured via Ingress
headlessForIngress := headlessSvcForParent(ing, "ingress")
ep = endpointSliceForService(headlessForIngress, "10.9.8.7")
mustCreate(t, fc, headlessForIngress)
mustCreate(t, fc, ep)
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
wantHosts["cluster.ingress.ts.net"] = []string{"10.9.8.7"}
expectHostsRecords(t, fc, wantHosts)
// 5. DNS records are updated if Ingress's MagicDNS name changes (i.e users changed spec.tls.hosts[0])
t.Log("test case 5")
mustUpdateStatus(t, fc, "test", "ts-ingress", func(ing *networkingv1.Ingress) {
ing.Status.LoadBalancer.Ingress[0].Hostname = "another.ingress.ts.net"
})
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
delete(wantHosts, "cluster.ingress.ts.net")
wantHosts["another.ingress.ts.net"] = []string{"10.9.8.7"}
expectHostsRecords(t, fc, wantHosts)
// 6. DNS records are updated if Ingress proxy's Pod IP changes
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
ep.Endpoints[0].Addresses = []string{"7.8.9.10"}
})
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"}
expectHostsRecords(t, fc, wantHosts)
}
func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: o.GetName(),
Namespace: "tailscale",
Labels: map[string]string{
LabelManaged: "true",
LabelParentName: o.GetName(),
LabelParentNamespace: o.GetNamespace(),
LabelParentType: typ,
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "None",
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{"foo": "bar"},
},
}
}
func endpointSliceForService(svc *corev1.Service, ip string) *discoveryv1.EndpointSlice {
return &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: svc.Name,
Namespace: svc.Namespace,
Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name},
},
Endpoints: []discoveryv1.Endpoint{{
Addresses: []string{ip},
}},
}
}
func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]string) {
t.Helper()
cm := new(corev1.ConfigMap)
if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil {
t.Fatalf("getting dnsconfig ConfigMap: %v", err)
}
if cm.Data == nil {
t.Fatal("dnsconfig ConfigMap has no data")
}
dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey]
if !ok {
t.Fatal("dnsconfig ConfigMap does not contain dnsconfig")
}
dnsConfig := &operatorutils.Records{}
if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil {
t.Fatalf("unmarshaling dnsconfig: %v", err)
}
if diff := cmp.Diff(dnsConfig.IP4, wantsHosts); diff != "" {
t.Fatalf("unexpected dns config (-got +want):\n%s", diff)
}
}

View File

@@ -19,14 +19,10 @@ import (
)
const (
operatorDeploymentFilesPath = "cmd/k8s-operator/deploy"
connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml"
proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml"
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
operatorDeploymentFilesPath = "cmd/k8s-operator/deploy"
crdPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml"
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
crdTemplatePath = helmTemplatesPath + "/connectors.yaml"
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
helmConditionalEnd = "{{- end -}}"
@@ -38,19 +34,19 @@ func main() {
}
repoRoot := "../../"
switch os.Args[1] {
case "helmcrd": // insert CRDs to Helm templates behind a installCRDs=true conditional check
log.Print("Adding CRDs to Helm templates")
case "helmcrd": // insert CRD to Helm templates behind a installCRDs=true conditional check
log.Print("Adding Connector CRD to Helm templates")
if err := generate("./"); err != nil {
log.Fatalf("error adding CRDs to Helm templates: %v", err)
log.Fatalf("error adding Connector CRD to Helm templates: %v", err)
}
return
case "staticmanifests": // generate static manifests from Helm templates (including the CRD)
default:
log.Fatalf("unknown option %s, known options are 'staticmanifests', 'helmcrd'", os.Args[1])
}
log.Printf("Inserting CRDs Helm templates")
log.Printf("Inserting CRD into the Helm templates")
if err := generate(repoRoot); err != nil {
log.Fatalf("error adding CRDs to Helm templates: %v", err)
log.Fatalf("error adding Connector CRD to Helm templates: %v", err)
}
defer func() {
if err := cleanup(repoRoot); err != nil {
@@ -110,54 +106,34 @@ func main() {
}
}
// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into
// the Helm chart templates behind .Values.installCRDs=true condition (true by
// default).
func generate(baseDir string) error {
addCRDToHelm := func(crdPath, crdTemplatePath string) error {
chartBytes, err := os.ReadFile(filepath.Join(baseDir, crdPath))
if err != nil {
return fmt.Errorf("error reading CRD contents: %w", err)
}
// Place a new temporary Helm template file with the templated CRD
// contents into Helm templates.
file, err := os.Create(filepath.Join(baseDir, crdTemplatePath))
if err != nil {
return fmt.Errorf("error creating CRD template file: %w", err)
}
if _, err := file.Write([]byte(helmConditionalStart)); err != nil {
return fmt.Errorf("error writing helm if statement start: %w", err)
}
if _, err := file.Write(chartBytes); err != nil {
return fmt.Errorf("error writing chart bytes: %w", err)
}
if _, err := file.Write([]byte(helmConditionalEnd)); err != nil {
return fmt.Errorf("error writing helm if-statement end: %w", err)
}
return nil
log.Print("Placing Connector CRD into Helm templates..")
chartBytes, err := os.ReadFile(filepath.Join(baseDir, crdPath))
if err != nil {
return fmt.Errorf("error reading CRD contents: %w", err)
}
if err := addCRDToHelm(connectorCRDPath, connectorCRDHelmTemplatePath); err != nil {
return fmt.Errorf("error adding Connector CRD to Helm templates: %w", err)
// Place a new temporary Helm template file with the templated CRD
// contents into Helm templates.
file, err := os.Create(filepath.Join(baseDir, crdTemplatePath))
if err != nil {
return fmt.Errorf("error creating CRD template file: %w", err)
}
if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil {
return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err)
if _, err := file.Write([]byte(helmConditionalStart)); err != nil {
return fmt.Errorf("error writing helm if statement start: %w", err)
}
if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil {
return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err)
if _, err := file.Write(chartBytes); err != nil {
return fmt.Errorf("error writing chart bytes: %w", err)
}
if _, err := file.Write([]byte(helmConditionalEnd)); err != nil {
return fmt.Errorf("error writing helm if-statement end: %w", err)
}
return nil
}
func cleanup(baseDir string) error {
log.Print("Cleaning up CRD from Helm templates")
if err := os.Remove(filepath.Join(baseDir, connectorCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up Connector CRD template: %w", err)
}
if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err)
}
if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err)
if err := os.Remove(filepath.Join(baseDir, crdTemplatePath)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error cleaning up CRD template: %w", err)
}
return nil
}

View File

@@ -42,7 +42,7 @@ func Test_generate(t *testing.T) {
t.Fatalf("Helm chart linter failed: %v", err)
}
// Test that default Helm install contains the Connector and ProxyClass CRDs.
// Test that default Helm install contains the CRD
installContentsWithCRD := bytes.NewBuffer([]byte{})
helmTemplateWithCRDCmd := exec.Command(helmCLIPath, "template", helmPackagePath)
helmTemplateWithCRDCmd.Stderr = os.Stderr
@@ -51,16 +51,10 @@ func Test_generate(t *testing.T) {
t.Fatalf("templating Helm chart with CRDs failed: %v", err)
}
if !strings.Contains(installContentsWithCRD.String(), "name: connectors.tailscale.com") {
t.Errorf("Connector CRD not found in default chart install")
}
if !strings.Contains(installContentsWithCRD.String(), "name: proxyclasses.tailscale.com") {
t.Errorf("ProxyClass CRD not found in default chart install")
}
if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") {
t.Errorf("DNSConfig CRD not found in default chart install")
t.Errorf("CRD not found in default chart install")
}
// Test that CRDs can be excluded from Helm chart install
// Test that CRD can be excluded from Helm chart install
installContentsWithoutCRD := bytes.NewBuffer([]byte{})
helmTemplateWithoutCRDCmd := exec.Command(helmCLIPath, "template", helmPackagePath, "--set", "installCRDs=false")
helmTemplateWithoutCRDCmd.Stderr = os.Stderr
@@ -69,12 +63,6 @@ func Test_generate(t *testing.T) {
t.Fatalf("templating Helm chart without CRDs failed: %v", err)
}
if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") {
t.Errorf("Connector CRD found in chart install that should not contain a CRD")
}
if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") {
t.Errorf("ProxyClass CRD found in chart install that should not contain a CRD")
}
if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") {
t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD")
t.Errorf("CRD found in chart install that should not contain a CRD")
}
}

View File

@@ -12,12 +12,10 @@ import (
"strings"
"sync"
"github.com/pkg/errors"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -28,12 +26,6 @@ import (
"tailscale.com/util/set"
)
const (
tailscaleIngressClassName = "tailscale" // ingressClass.metadata.name for tailscale IngressClass resource
tailscaleIngressControllerName = "tailscale.com/ts-ingress" // ingressClass.spec.controllerName for tailscale IngressClass resource
ingressClassDefaultAnnotation = "ingressclass.kubernetes.io/is-default-class" // we do not support this https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class
)
type IngressReconciler struct {
client.Client
@@ -117,10 +109,6 @@ func (a *IngressReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// This function adds a finalizer to ing, ensuring that we can handle orderly
// deprovisioning later.
func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, ing *networkingv1.Ingress) error {
if err := a.validateIngressClass(ctx); err != nil {
logger.Warnf("error validating tailscale IngressClass: %v. In future this might be a terminal error.", err)
}
if !slices.Contains(ing.Finalizers, FinalizerName) {
// This log line is printed exactly once during initial provisioning,
// because once the finalizer is in place this block gets skipped. So,
@@ -132,17 +120,6 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return fmt.Errorf("failed to add finalizer: %w", err)
}
}
proxyClass := proxyClassForObject(ing)
if proxyClass != "" {
if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil {
return fmt.Errorf("error verifying ProxyClass for Ingress: %w", err)
} else if !ready {
logger.Infof("ProxyClass %s specified for the Ingress, but is not (yet) Ready, waiting..", proxyClass)
return nil
}
}
a.mu.Lock()
a.managedIngresses.Add(ing.UID)
gaugeIngressResources.Set(int64(a.managedIngresses.Len()))
@@ -228,25 +205,10 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
continue
}
for _, p := range rule.HTTP.Paths {
// Send a warning if folks use Exact path type - to make
// it easier for us to support Exact path type matching
// in the future if needed.
// https://kubernetes.io/docs/concepts/services-networking/ingress/#path-types
if *p.PathType == networkingv1.PathTypeExact {
msg := "Exact path type strict matching is currently not supported and requests will be routed as for Prefix path type. This behaviour might change in the future."
logger.Warnf(fmt.Sprintf("Unsupported Path type exact for path %s. %s", p.Path, msg))
a.recorder.Eventf(ing, corev1.EventTypeWarning, "UnsupportedPathTypeExact", msg)
}
addIngressBackend(&p.Backend, p.Path)
}
}
if len(web.Handlers) == 0 {
logger.Warn("Ingress contains no valid backends")
a.recorder.Eventf(ing, corev1.EventTypeWarning, "NoValidBackends", "no valid backends")
return nil
}
crl := childResourceLabels(ing.Name, ing.Namespace, "ingress")
var tags []string
if tstr, ok := ing.Annotations[AnnotationTags]; ok {
@@ -264,11 +226,6 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
ServeConfig: sc,
Tags: tags,
ChildResourceLabels: crl,
ProxyClassName: proxyClass,
}
if val := ing.GetAnnotations()[AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy]; val == "true" {
sts.ForwardClusterTrafficViaL7IngressProxy = true
}
if _, err := a.ssr.Provision(ctx, logger, sts); err != nil {
@@ -310,28 +267,5 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
func (a *IngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
return ing != nil &&
ing.Spec.IngressClassName != nil &&
*ing.Spec.IngressClassName == tailscaleIngressClassName
}
// validateIngressClass attempts to validate that 'tailscale' IngressClass
// included in Tailscale installation manifests exists and has not been modified
// to attempt to enable features that we do not support.
func (a *IngressReconciler) validateIngressClass(ctx context.Context) error {
ic := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{
Name: tailscaleIngressClassName,
},
}
if err := a.Get(ctx, client.ObjectKeyFromObject(ic), ic); apierrors.IsNotFound(err) {
return errors.New("Tailscale IngressClass not found in cluster. Latest installation manifests include a tailscale IngressClass - please update")
} else if err != nil {
return fmt.Errorf("error retrieving 'tailscale' IngressClass: %w", err)
}
if ic.Spec.Controller != tailscaleIngressControllerName {
return fmt.Errorf("Tailscale Ingress class controller name %s does not match tailscale Ingress controller name %s. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ic.Spec.Controller, tailscaleIngressControllerName)
}
if ic.GetAnnotations()[ingressClassDefaultAnnotation] != "" {
return fmt.Errorf("%s annotation is set on 'tailscale' IngressClass, but Tailscale Ingress controller does not support default Ingress class. Ensure that you are using 'tailscale' IngressClass from latest Tailscale installation manifests", ingressClassDefaultAnnotation)
}
return nil
*ing.Spec.IngressClassName == "tailscale"
}

View File

@@ -1,270 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"testing"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"tailscale.com/ipn"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/types/ptr"
"tailscale.com/util/mak"
)
func TestTailscaleIngress(t *testing.T) {
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
fc := fake.NewFakeClient(tsIngressClass)
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
ingR := &IngressReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
tsnetServer: fakeTsnetServer,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// 1. Resources get created for regular Ingress
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
mustCreate(t, fc, ing)
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
})
expectReconciled(t, ingR, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
opts := configOpts{
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test",
}
serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://1.2.3.4:8080/"}}}},
}
opts.serveConfig = serveConfig
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
// 2. Ingress status gets updated with ingress proxy's MagicDNS name
// once that becomes available.
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
mak.Set(&secret.Data, "device_id", []byte("1234"))
mak.Set(&secret.Data, "device_fqdn", []byte("foo.tailnetxyz.ts.net"))
})
expectReconciled(t, ingR, "default", "test")
ing.Finalizers = append(ing.Finalizers, "tailscale.com/finalizer")
ing.Status.LoadBalancer = networkingv1.IngressLoadBalancerStatus{
Ingress: []networkingv1.IngressLoadBalancerIngress{
{Hostname: "foo.tailnetxyz.ts.net", Ports: []networkingv1.IngressPortStatus{{Port: 443, Protocol: "TCP"}}},
},
}
expectEqual(t, fc, ing, nil)
// 3. Resources get created for Ingress that should allow forwarding
// cluster traffic
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
mak.Set(&ing.ObjectMeta.Annotations, AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy, "true")
})
opts.shouldEnableForwardingClusterTrafficViaIngress = true
expectReconciled(t, ingR, "default", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// 4. Resources get cleaned up when Ingress class is unset
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
ing.Spec.IngressClassName = ptr.To("nginx")
})
expectReconciled(t, ingR, "default", "test")
expectReconciled(t, ingR, "default", "test") // deleting Ingress STS requires two reconciles
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
}
func TestTailscaleIngressWithProxyClass(t *testing.T) {
// Setup
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
Labels: map[string]string{"foo": "bar"},
Annotations: map[string]string{"bar.io/foo": "some-val"},
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
}
tsIngressClass := &networkingv1.IngressClass{ObjectMeta: metav1.ObjectMeta{Name: "tailscale"}, Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"}}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pc, tsIngressClass).
WithStatusSubresource(pc).
Build()
ft := &fakeTSClient{}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
ingR := &IngressReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
tsnetServer: fakeTsnetServer,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// 1. Ingress is created with no ProxyClass specified, default proxy
// resources get configured.
ing := &networkingv1.Ingress{
TypeMeta: metav1.TypeMeta{Kind: "Ingress", APIVersion: "networking.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: networkingv1.IngressSpec{
IngressClassName: ptr.To("tailscale"),
DefaultBackend: &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test",
Port: networkingv1.ServiceBackendPort{
Number: 8080,
},
},
},
TLS: []networkingv1.IngressTLS{
{Hosts: []string{"default-test"}},
},
},
}
mustCreate(t, fc, ing)
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ClusterIP: "1.2.3.4",
Ports: []corev1.ServicePort{{
Port: 8080,
Name: "http"},
},
},
})
expectReconciled(t, ingR, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
opts := configOpts{
stsName: shortName,
secretName: fullName,
namespace: "default",
parentType: "ingress",
hostname: "default-test",
}
serveConfig := &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{"${TS_CERT_DOMAIN}:443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://1.2.3.4:8080/"}}}},
}
opts.serveConfig = serveConfig
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
// 2. Ingress is updated to specify a ProxyClass, ProxyClass is not yet
// ready, so proxy resource configuration does not change.
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
mak.Set(&ing.ObjectMeta.Labels, LabelProxyClass, "custom-metadata")
})
expectReconciled(t, ingR, "default", "test")
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
// 3. ProxyClass is set to Ready by proxy-class reconciler. Ingress get
// reconciled and configuration from the ProxyClass is applied to the
// created proxy resources.
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
pc.Status = tsapi.ProxyClassStatus{
Conditions: []metav1.Condition{{
Status: metav1.ConditionTrue,
Type: string(tsapi.ProxyClassready),
ObservedGeneration: pc.Generation,
}}}
})
expectReconciled(t, ingR, "default", "test")
opts.proxyClass = pc.Name
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
// 4. tailscale.com/proxy-class label is removed from the Ingress, the
// Ingress gets reconciled and the custom ProxyClass configuration is
// removed from the proxy resources.
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
delete(ing.ObjectMeta.Labels, LabelProxyClass)
})
expectReconciled(t, ingR, "default", "test")
opts.proxyClass = ""
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
}

View File

@@ -1,283 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"fmt"
"slices"
"sync"
_ "embed"
"github.com/pkg/errors"
"go.uber.org/zap"
xslices "golang.org/x/exp/slices"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/yaml"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
const (
reasonNameserverCreationFailed = "NameserverCreationFailed"
reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent"
reasonNameserverCreated = "NameserverCreated"
messageNameserverCreationFailed = "Failed creating nameserver resources: %v"
messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present."
defaultNameserverImageRepo = "tailscale/k8s-nameserver"
// TODO (irbekrm): once we start publishing nameserver images for stable
// track, replace 'unstable' here with the version of this operator
// instance.
defaultNameserverImageTag = "unstable"
)
// NameserverReconciler knows how to create nameserver resources in cluster in
// response to users applying DNSConfig.
type NameserverReconciler struct {
client.Client
logger *zap.SugaredLogger
recorder record.EventRecorder
clock tstime.Clock
tsNamespace string
mu sync.Mutex // protects following
managedNameservers set.Slice[types.UID] // one or none
}
var (
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
)
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
logger := a.logger.With("dnsConfig", req.Name)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
var dnsCfg tsapi.DNSConfig
err = a.Get(ctx, req.NamespacedName, &dnsCfg)
if apierrors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
logger.Debugf("dnsconfig not found, assuming it was deleted")
return reconcile.Result{}, nil
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err)
}
if !dnsCfg.DeletionTimestamp.IsZero() {
ix := xslices.Index(dnsCfg.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
return reconcile.Result{}, nil
}
logger.Info("Cleaning up DNSConfig resources")
if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil {
logger.Errorf("error cleaning up reconciler resource: %v", err)
return res, err
}
dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...)
if err := a.Update(ctx, &dnsCfg); err != nil {
logger.Errorf("error removing finalizer: %v", err)
return reconcile.Result{}, err
}
logger.Infof("Nameserver resources cleaned up")
return reconcile.Result{}, nil
}
oldCnStatus := dnsCfg.Status.DeepCopy()
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
// An error encountered here should get returned by the Reconcile function.
if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil {
err = errors.Wrap(err, updateErr.Error())
}
}
return res, err
}
var dnsCfgs tsapi.DNSConfigList
if err := a.List(ctx, &dnsCfgs); err != nil {
return res, fmt.Errorf("error listing DNSConfigs: %w", err)
}
if len(dnsCfgs.Items) > 1 { // enforce DNSConfig to be a singleton
msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created."
logger.Error(msg)
a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
}
if !slices.Contains(dnsCfg.Finalizers, FinalizerName) {
logger.Infof("ensuring nameserver resources")
dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName)
if err := a.Update(ctx, &dnsCfg); err != nil {
msg := fmt.Sprintf(messageNameserverCreationFailed, err)
logger.Error(msg)
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg)
}
}
if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil {
return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err)
}
a.mu.Lock()
a.managedNameservers.Add(dnsCfg.UID)
a.mu.Unlock()
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace},
}
if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil {
return res, fmt.Errorf("error getting Service: %w", err)
}
if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" {
dnsCfg.Status.Nameserver = &tsapi.NameserverStatus{
IP: ip,
}
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated)
}
logger.Info("nameserver Service does not have an IP address allocated, waiting...")
return reconcile.Result{}, nil
}
func nameserverResourceLabels(name, namespace string) map[string]string {
labels := childResourceLabels(name, namespace, "nameserver")
labels["app.kubernetes.io/name"] = "tailscale"
labels["app.kubernetes.io/component"] = "nameserver"
return labels
}
func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
labels := nameserverResourceLabels(tsDNSCfg.Name, a.tsNamespace)
dCfg := &deployConfig{
ownerRefs: []metav1.OwnerReference{*metav1.NewControllerRef(tsDNSCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))},
namespace: a.tsNamespace,
labels: labels,
imageRepo: defaultNameserverImageRepo,
imageTag: defaultNameserverImageTag,
}
if tsDNSCfg.Spec.Nameserver.Image != nil && tsDNSCfg.Spec.Nameserver.Image.Repo != "" {
dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo
}
if tsDNSCfg.Spec.Nameserver.Image != nil && tsDNSCfg.Spec.Nameserver.Image.Tag != "" {
dCfg.imageTag = tsDNSCfg.Spec.Nameserver.Image.Tag
}
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
if err := deployable.updateObj(ctx, dCfg, a.Client); err != nil {
return fmt.Errorf("error reconciling %s: %w", deployable.kind, err)
}
}
return nil
}
// maybeCleanup removes DNSConfig from being tracked. The cluster resources
// created, will be automatically garbage collected as they are owned by the
// DNSConfig.
func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
a.mu.Lock()
a.managedNameservers.Remove(dnsCfg.UID)
a.mu.Unlock()
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
return nil
}
type deployable struct {
kind string
updateObj func(context.Context, *deployConfig, client.Client) error
}
type deployConfig struct {
imageRepo string
imageTag string
labels map[string]string
ownerRefs []metav1.OwnerReference
namespace string
}
var (
//go:embed deploy/manifests/nameserver/cm.yaml
cmYaml []byte
//go:embed deploy/manifests/nameserver/deploy.yaml
deployYaml []byte
//go:embed deploy/manifests/nameserver/sa.yaml
saYaml []byte
//go:embed deploy/manifests/nameserver/svc.yaml
svcYaml []byte
deployDeployable = deployable{
kind: "Deployment",
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
d := new(appsv1.Deployment)
if err := yaml.Unmarshal(deployYaml, &d); err != nil {
return fmt.Errorf("error unmarshalling Deployment yaml: %w", err)
}
d.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag)
d.ObjectMeta.Namespace = cfg.namespace
d.ObjectMeta.Labels = cfg.labels
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
updateF := func(oldD *appsv1.Deployment) {
oldD.Spec = d.Spec
}
_, err := createOrUpdate[appsv1.Deployment](ctx, kubeClient, cfg.namespace, d, updateF)
return err
},
}
saDeployable = deployable{
kind: "ServiceAccount",
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
sa := new(corev1.ServiceAccount)
if err := yaml.Unmarshal(saYaml, &sa); err != nil {
return fmt.Errorf("error unmarshalling ServiceAccount yaml: %w", err)
}
sa.ObjectMeta.Labels = cfg.labels
sa.ObjectMeta.OwnerReferences = cfg.ownerRefs
sa.ObjectMeta.Namespace = cfg.namespace
_, err := createOrUpdate(ctx, kubeClient, cfg.namespace, sa, func(*corev1.ServiceAccount) {})
return err
},
}
svcDeployable = deployable{
kind: "Service",
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
svc := new(corev1.Service)
if err := yaml.Unmarshal(svcYaml, &svc); err != nil {
return fmt.Errorf("error unmarshalling Service yaml: %w", err)
}
svc.ObjectMeta.Labels = cfg.labels
svc.ObjectMeta.OwnerReferences = cfg.ownerRefs
svc.ObjectMeta.Namespace = cfg.namespace
_, err := createOrUpdate[corev1.Service](ctx, kubeClient, cfg.namespace, svc, func(*corev1.Service) {})
return err
},
}
cmDeployable = deployable{
kind: "ConfigMap",
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
cm := new(corev1.ConfigMap)
if err := yaml.Unmarshal(cmYaml, &cm); err != nil {
return fmt.Errorf("error unmarshalling ConfigMap yaml: %w", err)
}
cm.ObjectMeta.Labels = cfg.labels
cm.ObjectMeta.OwnerReferences = cfg.ownerRefs
cm.ObjectMeta.Namespace = cfg.namespace
_, err := createOrUpdate[corev1.ConfigMap](ctx, kubeClient, cfg.namespace, cm, func(cm *corev1.ConfigMap) {})
return err
},
}
)

View File

@@ -1,127 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
// tailscale-operator provides a way to expose services running in a Kubernetes
// cluster to your Tailnet and to make Tailscale nodes available to cluster
// workloads
package main
import (
"encoding/json"
"testing"
"time"
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/yaml"
operatorutils "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/tstest"
"tailscale.com/util/mak"
)
func TestNameserverReconciler(t *testing.T) {
dnsCfg := &tsapi.DNSConfig{
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig", APIVersion: "tailscale.com/v1alpha1"},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
Spec: tsapi.DNSConfigSpec{
Nameserver: &tsapi.Nameserver{
Image: &tsapi.Image{
Repo: "test",
Tag: "v0.0.1",
},
},
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(dnsCfg).
WithStatusSubresource(dnsCfg).
Build()
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
cl := tstest.NewClock(tstest.ClockOpts{})
nr := &NameserverReconciler{
Client: fc,
clock: cl,
logger: zl.Sugar(),
tsNamespace: "tailscale",
}
expectReconciled(t, nr, "", "test")
// Verify that nameserver Deployment has been created and has the expected fields.
wantsDeploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: "tailscale"}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}}
if err := yaml.Unmarshal(deployYaml, wantsDeploy); err != nil {
t.Fatalf("unmarshalling yaml: %v", err)
}
dnsCfgOwnerRef := metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))
wantsDeploy.OwnerReferences = []metav1.OwnerReference{*dnsCfgOwnerRef}
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.1"
wantsDeploy.Namespace = "tailscale"
labels := nameserverResourceLabels("test", "tailscale")
wantsDeploy.ObjectMeta.Labels = labels
expectEqual(t, fc, wantsDeploy, nil)
// Verify that DNSConfig advertizes the nameserver's Service IP address,
// has the ready status condition and tailscale finalizer.
mustUpdate(t, fc, "tailscale", "nameserver", func(svc *corev1.Service) {
svc.Spec.ClusterIP = "1.2.3.4"
})
expectReconciled(t, nr, "", "test")
dnsCfg.Status.Nameserver = &tsapi.NameserverStatus{
IP: "1.2.3.4",
}
dnsCfg.Finalizers = []string{FinalizerName}
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
Type: string(tsapi.NameserverReady),
Status: metav1.ConditionTrue,
Reason: reasonNameserverCreated,
Message: reasonNameserverCreated,
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
})
expectEqual(t, fc, dnsCfg, nil)
// // Verify that nameserver image gets updated to match DNSConfig spec.
mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) {
dnsCfg.Spec.Nameserver.Image.Tag = "v0.0.2"
})
expectReconciled(t, nr, "", "test")
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.2"
expectEqual(t, fc, wantsDeploy, nil)
// Verify that when another actor sets ConfigMap data, it does not get
// overwritten by nameserver reconciler.
dnsRecords := &operatorutils.Records{Version: "v1alpha1", IP4: map[string][]string{"foo.ts.net": {"1.2.3.4"}}}
bs, err := json.Marshal(dnsRecords)
if err != nil {
t.Fatalf("error marshalling ConfigMap contents: %v", err)
}
mustUpdate(t, fc, "tailscale", "dnsrecords", func(cm *corev1.ConfigMap) {
mak.Set(&cm.Data, "records.json", string(bs))
})
expectReconciled(t, nr, "", "test")
wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords",
Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}},
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
Data: map[string]string{"records.json": string(bs)},
}
expectEqual(t, fc, wantCm, nil)
// Verify that if dnsconfig.spec.nameserver.image.{repo,tag} are unset,
// the nameserver image defaults to tailscale/k8s-nameserver:unstable.
mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) {
dnsCfg.Spec.Nameserver.Image = nil
})
expectReconciled(t, nr, "", "test")
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "tailscale/k8s-nameserver:unstable"
expectEqual(t, fc, wantsDeploy, nil)
}

Some files were not shown because too many files have changed in this diff Show More