Compare commits

..

1 Commits

Author SHA1 Message Date
Brad Fitzpatrick
a794630f60 wgengine/magicsock: add controlknob tunable for session timeout experiments
Updates #TODO

Change-Id: Ifb7ee2b69545cbc457aa2bf4c4744f431edb36e2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-10-06 06:59:17 -07:00
328 changed files with 6424 additions and 26762 deletions

View File

@@ -1,28 +0,0 @@
name: checklocks
on:
push:
branches:
- main
pull_request:
paths:
- '**/*.go'
- '.github/workflows/checklocks.yml'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
checklocks:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build checklocks
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
- name: Run checklocks vet
# TODO: remove || true once we have applied checklocks annotations everywhere.
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true

View File

@@ -31,10 +31,10 @@ jobs:
cache: false
- name: golangci-lint
# Note: this is the 'v3' tag as of 2023-08-14
# Note: this is the 'v3' tag as of 2023-04-17
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.54.2
version: v1.52.2
# Show only new issues if it's a pull request.
only-new-issues: true

View File

@@ -1,24 +0,0 @@
name: "Kubernetes manifests"
on:
pull_request:
paths:
- './cmd/k8s-operator/'
- '.github/workflows/kubemanifests.yaml'
# Cancel workflow run if there is a newer push to the same PR for which it is
# running
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
testchart:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Build and lint Helm chart
run: |
eval `./tool/go run ./cmd/mkversion`
./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart'
./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz"

View File

@@ -22,7 +22,8 @@ on:
- "main"
- "release-branch/*"
pull_request:
# all PRs on all branches
branches:
- "*"
merge_group:
branches:
- "main"
@@ -38,42 +39,14 @@ concurrency:
cancel-in-progress: true
jobs:
race-root-integration:
runs-on: ubuntu-22.04
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
- shard: '1/4'
- shard: '2/4'
- shard: '3/4'
- shard: '4/4'
steps:
- name: checkout
uses: actions/checkout@v4
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: integration tests as root
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
env:
TS_TEST_SHARD: ${{ matrix.shard }}
test:
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
- goarch: amd64
coverflags: "-coverprofile=/tmp/coverage.out"
- goarch: amd64
buildflags: "-race"
shard: '1/3'
- goarch: amd64
buildflags: "-race"
shard: '2/3'
- goarch: amd64
buildflags: "-race"
shard: '3/3'
- goarch: "386" # thanks yaml
runs-on: ubuntu-22.04
steps:
@@ -97,7 +70,6 @@ jobs:
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
- name: build all
if: matrix.buildflags == '' # skip on race builder
run: ./tool/go build ${{matrix.buildflags}} ./...
env:
GOARCH: ${{ matrix.goarch }}
@@ -119,15 +91,9 @@ jobs:
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}}
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
TS_TEST_SHARD: ${{ matrix.shard }}
- name: Publish to coveralls.io
if: matrix.coverflags != '' # only publish results if we've tracked coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: /tmp/coverage.out
- name: bench all
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
@@ -196,17 +162,7 @@ jobs:
HOME: "/tmp"
TMPDIR: "/tmp"
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
race-build:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v4
- name: build all
run: ./tool/go install -race ./cmd/...
- name: build tests
run: ./tool/go test -race -exec=true ./...
cross: # cross-compile checks, build only.
strategy:
fail-fast: false # don't abort the entire matrix if one element fails

View File

@@ -1,52 +0,0 @@
name: update-webclient-prebuilt
on:
# manually triggered
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
update-webclient-prebuilt:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run go get
run: |
./tool/go version # build gocross if needed using regular GOPROXY
GOPROXY=direct ./tool/go get github.com/tailscale/web-client-prebuilt
./tool/go mod tidy
- name: Get access token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
id: generate-token
with:
# TODO(will): this should use the code updater app rather than licensing.
# It has the same permissions, so not a big deal, but still.
app_id: ${{ secrets.LICENSING_APP_ID }}
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
id: pull-request
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: OSS Updater <noreply+oss-updater@tailscale.com>
committer: OSS Updater <noreply+oss-updater@tailscale.com>
branch: actions/update-webclient-prebuilt
commit-message: "go.mod: update web-client-prebuilt module"
title: "go.mod: update web-client-prebuilt module"
body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true
delete-branch: true
reviewers: ${{ github.triggering_actor }}
- name: Summary
if: ${{ steps.pull-request.outputs.pull-request-number }}
run: echo "${{ steps.pull-request.outputs.pull-request-operation}} ${{ steps.pull-request.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY

View File

@@ -1 +1 @@
1.55.0
1.51.0

4
api.md
View File

@@ -209,6 +209,10 @@ You can also [list all devices in the tailnet](#list-tailnet-devices) to get the
"192.68.0.21:59128"
],
// derp (string) is the IP:port of the DERP server currently being used.
// Learn about DERP servers at https://tailscale.com/kb/1232/.
"derp":"",
// mappingVariesByDestIP (boolean) is 'true' if the host's NAT mappings
// vary based on the destination IP.
"mappingVariesByDestIP":false,

View File

@@ -1,221 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package appc implements App Connectors.
// An AppConnector provides DNS domain oriented routing of traffic. An App
// Connector becomes a DNS server for a peer, authoritative for the set of
// configured domains. DNS resolution of the target domain triggers dynamic
// publication of routes to ensure that traffic to the domain is routed through
// the App Connector.
package appc
import (
"net/netip"
"slices"
"strings"
"sync"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
)
// RouteAdvertiser is an interface that allows the AppConnector to advertise
// newly discovered routes that need to be served through the AppConnector.
type RouteAdvertiser interface {
// AdvertiseRoute adds a new route advertisement if the route is not already
// being advertised.
AdvertiseRoute(netip.Prefix) error
}
// AppConnector is an implementation of an AppConnector that performs
// its function as a subsystem inside of a tailscale node. At the control plane
// side App Connector routing is configured in terms of domains rather than IP
// addresses.
// The AppConnectors responsibility inside tailscaled is to apply the routing
// and domain configuration as supplied in the map response.
// DNS requests for configured domains are observed. If the domains resolve to
// routes not yet served by the AppConnector the local node configuration is
// updated to advertise the new route.
type AppConnector struct {
logf logger.Logf
routeAdvertiser RouteAdvertiser
// mu guards the fields that follow
mu sync.Mutex
// domains is a map of lower case domain names with no trailing dot, to a
// list of resolved IP addresses.
domains map[string][]netip.Addr
// wildcards is the list of domain strings that match subdomains.
wildcards []string
}
// NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
return &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
}
}
// UpdateDomains replaces the current set of configured domains with the
// supplied set of domains. Domains must not contain a trailing dot, and should
// be lower case. If the domain contains a leading '*' label it matches all
// subdomains of a domain.
func (e *AppConnector) UpdateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
var oldDomains map[string][]netip.Addr
oldDomains, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
e.wildcards = e.wildcards[:0]
for _, d := range domains {
d = strings.ToLower(d)
if len(d) == 0 {
continue
}
if strings.HasPrefix(d, "*.") {
e.wildcards = append(e.wildcards, d[2:])
continue
}
e.domains[d] = oldDomains[d]
delete(oldDomains, d)
}
// Ensure that still-live wildcards addresses are preserved as well.
for d, addrs := range oldDomains {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(d, wc) {
e.domains[d] = addrs
break
}
}
}
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
}
// Domains returns the currently configured domain list.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
defer e.mu.Unlock()
return views.SliceOf(xmaps.Keys(e.domains))
}
// DomainRoutes returns a map of domains to resolved IP
// addresses.
func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
e.mu.Lock()
defer e.mu.Unlock()
drCopy := make(map[string][]netip.Addr)
for k, v := range e.domains {
drCopy[k] = append(drCopy[k], v...)
}
return drCopy
}
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return
}
if err := p.SkipAllQuestions(); err != nil {
return
}
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
domain := h.Name.String()
if len(domain) == 0 {
return
}
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
e.logf("[v2] observed DNS response for %s", domain)
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
}
continue
}
var addr netip.Addr
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
}
addr = netip.AddrFrom4(r.A)
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
}
addr = netip.AddrFrom16(r.AAAA)
default:
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
if slices.Contains(addrs, addr) {
continue
}
// TODO(raggi): check for existing prefixes
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
e.logf("failed to advertise route for %v: %v", addr, err)
continue
}
e.logf("[v2] advertised route for %v: %v", domain, addr)
e.mu.Lock()
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
}
}

View File

@@ -1,162 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package appc
import (
"net/netip"
"reflect"
"slices"
"testing"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/util/must"
)
func TestUpdateDomains(t *testing.T) {
a := NewAppConnector(t.Logf, nil)
a.UpdateDomains([]string{"example.com"})
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestDomainRoutes(t *testing.T) {
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")},
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
}
func TestObserveDNSResponse(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
// a has no domains configured, so it should not advertise any routes
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if !slices.Equal(rc.routes, wantRoutes) {
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
}
}
func TestWildcardDomains(t *testing.T) {
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
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)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.UpdateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
}
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
func dnsResponse(domain, address string) []byte {
addr := netip.MustParseAddr(address)
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
switch addr.BitLen() {
case 32:
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: addr.As4(),
},
)
case 128:
b.AAAAResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AAAAResource{
AAAA: addr.As16(),
},
)
default:
panic("invalid address length")
}
return must.Get(b.Finish())
}
// routeCollector is a test helper that collects the list of routes advertised
type routeCollector struct {
routes []netip.Prefix
}
// routeCollector implements RouteAdvertiser
var _ RouteAdvertiser = (*routeCollector)(nil)
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}

View File

@@ -40,12 +40,3 @@ type SetPushDeviceTokenRequest struct {
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
PushDeviceToken string
}
// ReloadConfigResponse is the response to a LocalAPI reload-config request.
//
// There are three possible outcomes: (false, "") if no config mode in use,
// (true, "") on success, or (false, "error message") on failure.
type ReloadConfigResponse struct {
Reloaded bool // whether the config was reloaded
Err string // any error message
}

View File

@@ -679,26 +679,6 @@ func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
return nil
}
// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
// the machine is optimally configured to forward UDP packets as a subnet router
// or exit node.
func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-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 check-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
@@ -1264,22 +1244,6 @@ func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProf
return current, all, err
}
// ReloadConfig reloads the config file, if possible.
func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/reload-config", 200, nil)
if err != nil {
return
}
res, err := decodeJSON[apitype.ReloadConfigResponse](body)
if err != nil {
return
}
if res.Err != "" {
return false, errors.New(res.Err)
}
return res.Reloaded, nil
}
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
// profile is not assigned an ID until it is persisted after a successful login.
// In order to login to the new profile, the user must call LoginInteractive.
@@ -1394,21 +1358,6 @@ func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt)
}, nil
}
// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available
// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
// ClientVersion that says that we are up to date.
func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
body, err := lc.get200(ctx, "/localapi/v0/update/check")
if err != nil {
return nil, err
}
cv, err := decodeJSON[tailcfg.ClientVersion](body)
if err != nil {
return nil, err
}
return &cv, nil
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by LocalClient.WatchIPNBus.
//

View File

@@ -4,7 +4,6 @@
package web
import (
"io/fs"
"log"
"net/http"
"net/http/httputil"
@@ -23,19 +22,7 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
cleanup := startDevServer()
return devServerProxy(), cleanup
}
fsys := prebuilt.FS()
fileserver := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := fs.Stat(fsys, strings.TrimPrefix(r.URL.Path, "/"))
if os.IsNotExist(err) {
// rewrite request to just fetch /index.html and let
// the frontend router handle it.
r = r.Clone(r.Context())
r.URL.Path = "/"
}
fileserver.ServeHTTP(w, r)
}), nil
return http.FileServer(http.FS(prebuilt.FS())), nil
}
// startDevServer starts the JS dev server that does on-demand rebuilding
@@ -48,7 +35,7 @@ func startDevServer() (cleanup func()) {
node := filepath.Join(root, "tool", "node")
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
log.Printf("installing JavaScript deps using %s...", yarn)
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
if err != nil {
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)

View File

@@ -1,233 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"net/url"
"strings"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
)
const (
sessionCookieName = "TS-Web-Session"
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
)
// browserSession holds data about a user's browser session
// on the full management web client.
type browserSession struct {
// ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie.
ID string
SrcNode tailcfg.NodeID
SrcUser tailcfg.UserID
AuthID string // from tailcfg.WebClientAuthResponse
AuthURL string // from tailcfg.WebClientAuthResponse
Created time.Time
Authenticated bool
}
// isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management
// web client.
//
// isAuthorized is true only when s.Authenticated is true (i.e.
// the user has authenticated the session) and the session is not
// expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isAuthorized(now time.Time) bool {
switch {
case s == nil:
return false
case !s.Authenticated:
return false // awaiting auth
case s.isExpired(now):
return false // expired
}
return true
}
// isExpired reports true if s is expired.
// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isExpired(now time.Time) bool {
return !s.Created.IsZero() && now.After(s.expires())
}
// expires reports when the given session expires.
func (s *browserSession) expires() time.Time {
return s.Created.Add(sessionCookieExpiry)
}
var (
errNoSession = errors.New("no-browser-session")
errNotUsingTailscale = errors.New("not-using-tailscale")
errTaggedRemoteSource = errors.New("tagged-remote-source")
errTaggedLocalSource = errors.New("tagged-local-source")
errNotOwner = errors.New("not-owner")
)
// getSession retrieves the browser session associated with the request,
// if one exists.
//
// An error is returned in any of the following cases:
//
// - (errNotUsingTailscale) The request was not made over tailscale.
//
// - (errNoSession) The request does not have a session.
//
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
// Users must use their own user-owned devices to manage other nodes'
// web clients.
//
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
// access to web clients.
//
// - (errNotOwner) The source is not the owner of this client (if the
// client is user-owned). Only the owner is allowed to manage the
// node via the web client.
//
// If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized.
//
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
switch {
case whoIsErr != nil:
return nil, nil, errNotUsingTailscale
case statusErr != nil:
return nil, whoIs, statusErr
case status.Self == nil:
return nil, whoIs, errors.New("missing self node in tailscale status")
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
return nil, whoIs, errTaggedLocalSource
case whoIs.Node.IsTagged():
return nil, whoIs, errTaggedRemoteSource
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
return nil, whoIs, errNotOwner
}
srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID
cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
return nil, whoIs, errNoSession
} else if err != nil {
return nil, whoIs, err
}
v, ok := s.browserSessions.Load(cookie.Value)
if !ok {
return nil, whoIs, errNoSession
}
session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// Maybe the source browser's machine was logged out and then back in as a different node.
// Return errNoSession because there is no session for this user.
return nil, whoIs, errNoSession
} else if session.isExpired(s.timeNow()) {
// Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID)
return nil, whoIs, errNoSession
}
return session, whoIs, nil
}
// newSession creates a new session associated with the given source user/node,
// and stores it back to the session cache. Creating of a new session includes
// generating a new auth URL from the control server.
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
sid, err := s.newSessionID()
if err != nil {
return nil, err
}
session := &browserSession{
ID: sid,
SrcNode: src.Node.ID,
SrcUser: src.UserProfile.ID,
Created: s.timeNow(),
}
if s.controlSupportsCheckMode(ctx) {
// control supports check mode, so get a new auth URL and return.
a, err := s.newAuthURL(ctx, src.Node.ID)
if err != nil {
return nil, err
}
session.AuthID = a.ID
session.AuthURL = a.URL
} else {
// control does not support check mode, so there is no additional auth we can do.
session.Authenticated = true
}
s.browserSessions.Store(sid, session)
return session, nil
}
// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
// We assume that only "tailscale.com" control servers support check mode.
// This allows the web client to be used with non-standard control servers.
// If an error occurs getting the control URL, this method returns true to fail closed.
//
// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return true
}
controlURL, err := url.Parse(prefs.ControlURL)
if err != nil {
return true
}
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
}
// awaitUserAuth blocks until the given session auth has been completed
// by the user on the control server, then updates the session cache upon
// completion. An error is returned if control auth failed for any reason.
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
if session.isAuthorized(s.timeNow()) {
return nil // already authorized
}
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
if err != nil {
// Clean up the session. Doing this on any error from control
// server to avoid the user getting stuck with a bad session
// cookie.
s.browserSessions.Delete(session.ID)
return err
}
if a.Complete {
session.Authenticated = a.Complete
s.browserSessions.Store(session.ID, session)
}
return nil
}
func (s *Server) newSessionID() (string, error) {
raw := make([]byte, 16)
for i := 0; i < 5; i++ {
if _, err := rand.Read(raw); err != nil {
return "", err
}
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
if _, ok := s.browserSessions.Load(cookie); !ok {
return cookie, nil
}
}
return "", errors.New("too many collisions generating new session; please refresh page")
}

View File

@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
<link rel="stylesheet" type="text/css" href="/src/index.css" />
<link rel="preload" as="font" href="/src/assets/fonts/Inter.var.latin.woff2" type="font/woff2" crossorigin />
</head>
<body>
<noscript>

View File

@@ -8,21 +8,17 @@
},
"private": true,
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-popover": "^1.0.6",
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"wouter": "^2.11.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/classnames": "^2.2.10",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.23.1",
"eslint-config-react-app": "^7.0.1",
"postcss": "^8.4.31",
"postcss": "^8.4.27",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
@@ -36,26 +32,11 @@
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit && eslint 'src/**/*.{ts,tsx,js,jsx}'",
"lint": "tsc --noEmit",
"test": "vitest",
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
},
"eslintConfig": {
"extends": [
"react-app"
],
"plugins": [
"react-hooks"
],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
},
"settings": {
"projectRoot": "client/web/package.json"
}
},
"prettier": {
"semi": false,
"printWidth": 80

View File

@@ -9,7 +9,6 @@ package web
import (
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
@@ -19,17 +18,21 @@ import (
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// are authorized to use the web client.
// If the user is not authorized to use the client, an error is returned.
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
// It reports true if the request is authorized to continue, and false otherwise.
// authorizeQNAP manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
_, resp, err := qnapAuthn(r)
if err != nil {
return false, err
http.Error(w, err.Error(), http.StatusUnauthorized)
return false
}
if resp.IsAdmin == 0 {
return false, errors.New("user is not an admin")
http.Error(w, "user is not an admin", http.StatusForbidden)
return false
}
return true, nil
return true
}
type qnapAuthResponse struct {

View File

@@ -1,8 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
let csrfToken: string
let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
// apiFetch wraps the standard JS fetch function with csrf header
@@ -13,19 +9,15 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8
// (i.e. provide `/data` rather than `api/data`).
export function apiFetch(
endpoint: string,
method: "GET" | "POST" | "PATCH",
method: "GET" | "POST",
body?: any,
params?: Record<string, string>
): Promise<Response> {
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams(params)
if (synoToken) {
nextParams.set("SynoToken", synoToken)
} else {
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `api${endpoint}${search ? `?${search}` : ""}`
@@ -70,10 +62,6 @@ function updateCsrfToken(r: Response) {
}
}
export function setSynoToken(token?: string) {
synoToken = token
}
export function setUnraidCsrfToken(token?: string) {
unraidCsrfToken = token
}

View File

@@ -1,4 +0,0 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 324 B

View File

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 522 B

View File

@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 704 B

View File

@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 236 B

View File

@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 203 B

View File

@@ -1,11 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_14876_118476)">
<path d="M8.00065 14.6667C11.6825 14.6667 14.6673 11.6819 14.6673 8.00004C14.6673 4.31814 11.6825 1.33337 8.00065 1.33337C4.31875 1.33337 1.33398 4.31814 1.33398 8.00004C1.33398 11.6819 4.31875 14.6667 8.00065 14.6667Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 4V8L10.6667 9.33333" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_14876_118476">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 678 B

View File

@@ -1,11 +0,0 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15367_14595)">
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_15367_14595">
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 738 B

View File

@@ -1,4 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4.16663V15.8333" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16602 10H15.8327" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 329 B

View File

@@ -1,4 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 500 B

View File

@@ -1,4 +0,0 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 635 B

View File

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 506 B

View File

@@ -1,28 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
import Badge from "src/ui/badge"
/**
* ACLTag handles the display of an ACL tag.
*/
export default function ACLTag({
tag,
className,
}: {
tag: string
className?: string
}) {
return (
<Badge
variant="status"
color="outline"
className={cx("flex text-xs items-center", className)}
>
<span className="font-medium">tag:</span>
<span className="text-gray-500">{tag.replace("tag:", "")}</span>
</Badge>
)
}

View File

@@ -1,128 +1,124 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useEffect } from "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 HomeView from "src/components/views/home-view"
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 } from "src/hooks/auth"
import React from "react"
import { Footer, Header, IP, State } from "src/components/legacy"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { Link, Route, Router, Switch, useLocation } from "wouter"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
export default function App() {
const { data: auth, loading: loadingAuth, newSession } = useAuth()
// TODO(sonia): use isPosting value from useNodeData
// to fill loading states.
const { data, refreshData, updateNode } = useNodeData()
return (
<main className="min-w-sm max-w-lg mx-auto py-14 px-5">
{loadingAuth || !auth ? (
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
if (!data) {
// TODO(sonia): add a loading view
return <div className="text-center py-14">Loading...</div>
}
const needsLogin = data?.Status === "NeedsLogin" || data?.Status === "NoState"
return !needsLogin &&
(data.DebugMode === "login" || data.DebugMode === "full") ? (
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
{data.DebugMode === "login" ? (
<LoginView {...data} />
) : (
<WebClient auth={auth} newSession={newSession} />
<ManageView {...data} />
)}
</main>
)
}
function WebClient({
auth,
newSession,
}: {
auth: AuthResponse
newSession: () => Promise<void>
}) {
const { data, refreshData, nodeUpdaters } = useNodeData()
useEffect(() => {
refreshData()
}, [auth, refreshData])
return !data ? (
<div className="text-center py-14">Loading...</div>
) : data.Status === "NeedsLogin" ||
data.Status === "NoState" ||
data.Status === "Stopped" ? (
// Client not on a tailnet, render login.
<LoginView data={data} refreshData={refreshData} />
<Footer className="mt-20" licensesURL={data.LicensesURL} />
</div>
) : (
// Otherwise render the new web client.
<>
<Router base={data.URLPrefix}>
<Header node={data} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
</Route>
<Route path="/subnets">
<SubnetRouterView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/ssh">
<SSHView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/serve">{/* TODO */}Share local content</Route>
<Route path="/update">
<UpdatingView
versionInfo={data.ClientVersion}
currentVersion={data.IPNVersion}
/>
</Route>
<Route>
<h2 className="mt-8">Page not found</h2>
</Route>
</Switch>
</Router>
</>
// Legacy client UI
<div className="py-14">
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
<IP data={data} />
<State data={data} updateNode={updateNode} />
</main>
<Footer licensesURL={data.LicensesURL} />
</div>
)
}
function Header({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
const [loc] = useLocation()
function LoginView(props: NodeData) {
return (
<>
<div className="flex justify-between mb-12">
<div className="flex gap-3">
<TailscaleIcon />
<div className="inline text-neutral-800 text-lg font-medium leading-snug">
{node.DomainName}
<div className="pb-52 mx-auto">
<TailscaleLogo />
</div>
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
<div className="flex gap-2.5">
<ProfilePic url={props.Profile.ProfilePicURL} />
<div className="font-medium">
<div className="text-neutral-500 text-xs uppercase tracking-wide">
Owned by
</div>
<div className="text-neutral-800 text-sm leading-tight">
{/* TODO(sonia): support tagged node profile view more eloquently */}
{props.Profile.LoginName}
</div>
</div>
</div>
<LoginToggle node={node} auth={auth} newSession={newSession} />
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
<div className="flex gap-3">
<ConnectedDeviceIcon />
<div className="text-neutral-800">
<div className="text-lg font-medium leading-[25.20px]">
{props.DeviceName}
</div>
<div className="text-sm leading-tight">{props.IP}</div>
</div>
</div>
<button className="button button-blue ml-6">Access</button>
</div>
</div>
{loc !== "/" && loc !== "/update" && (
<Link
to="/"
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
>
&larr; Back to {node.DeviceName}
</Link>
)}
</>
)
}
function ManageView(props: NodeData) {
return (
<div className="px-5">
<div className="flex justify-between mb-12">
<TailscaleIcon />
<div className="flex">
<p className="mr-2">{props.Profile.LoginName}</p>
{/* TODO(sonia): support tagged node profile view more eloquently */}
<ProfilePic url={props.Profile.ProfilePicURL} />
</div>
</div>
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
<div className="flex justify-between items-center text-lg">
<div className="flex items-center">
<ConnectedDeviceIcon />
<p className="font-medium ml-3">{props.DeviceName}</p>
</div>
<p className="tracking-widest">{props.IP}</p>
</div>
</div>
<p className="text-gray-500 pt-2">
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
</p>
</div>
)
}
function ProfilePic({ url }: { url: string }) {
return (
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{url ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import { NodeData } from "src/hooks/node-data"
/**
* AdminContainer renders its contents only if the node's control
* server has an admin panel.
*
* TODO(sonia,will): Similarly, this could also hide the contents
* if the viewing user is a non-admin.
*/
export function AdminContainer({
node,
children,
className,
}: {
node: NodeData
children: React.ReactNode
className?: string
}) {
if (!node.ControlAdminURL.includes("tailscale.com")) {
// Admin panel only exists on Tailscale control servers.
return null
}
return <div className={className}>{children}</div>
}
/**
* AdminLink renders its contents wrapped in a link to the node's control
* server admin panel.
*
* AdminLink is meant for use only inside of a AdminContainer component,
* to avoid rendering a link when the node's control server does not have
* an admin panel.
*/
export function AdminLink({
node,
children,
path,
}: {
node: NodeData
children: React.ReactNode
path: string // admin path, e.g. "/settings/webhooks"
}) {
return (
<a
href={`${node.ControlAdminURL}${path}`}
className="link"
target="_blank"
rel="noreferrer"
>
{children}
</a>
)
}

View File

@@ -1,533 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useMemo, useRef, useState } from "react"
import { ReactComponent as Check } from "src/assets/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import useExitNodes, {
ExitNode,
noExitNode,
runAsExitNode,
trimDNSSuffix,
} from "src/hooks/exit-nodes"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
export default function ExitNodeSelector({
className,
node,
nodeUpdaters,
disabled,
}: {
className?: string
node: NodeData
nodeUpdaters: NodeUpdaters
disabled?: boolean
}) {
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
const handleSelect = useCallback(
(n: ExitNode) => {
setOpen(false)
if (n.ID === selected.ID) {
return // no update
}
const old = selected
setSelected(n) // optimistic UI update
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
},
[nodeUpdaters, selected]
)
const [
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
] = useMemo(
() => [
selected.ID === noExitNode.ID,
selected.ID === runAsExitNode.ID,
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
],
[selected]
)
return (
<Popover
open={disabled ? false : open}
onOpenChange={setOpen}
side="bottom"
sideOffset={5}
align="start"
alignOffset={8}
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
>
<div
className={cx(
"p-1.5 rounded-md border flex items-stretch gap-1.5",
{
"border-gray-200": none,
"bg-amber-600 border-amber-600": advertising,
"bg-indigo-500 border-indigo-500": using,
},
className
)}
>
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white hover:bg-stone-100": none,
"bg-amber-600 hover:bg-orange-400": advertising,
"bg-indigo-500 hover:bg-indigo-400": using,
"cursor-not-allowed": disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
>
<p
className={cx(
"text-neutral-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
{ "bg-opacity-70 text-white": advertising || using }
)}
>
Exit node
</p>
<div className="flex items-center">
<p
className={cx("text-neutral-800", {
"text-white": advertising || using,
})}
>
{selected.Location && (
<>
<CountryFlag code={selected.Location.CountryCode} />{" "}
</>
)}
{selected === runAsExitNode
? "Running as exit node"
: selected.Name}
</p>
<ChevronDown
className={cx("ml-1", {
"stroke-neutral-800": none,
"stroke-white": advertising || using,
})}
/>
</div>
</button>
{(advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white", {
"bg-orange-400": advertising,
"bg-indigo-400": using,
"cursor-not-allowed": disabled,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
disabled={disabled}
>
Disable
</button>
)}
</div>
</Popover>
)
}
function toSelectedExitNode(data: NodeData): ExitNode {
if (data.AdvertisingExitNode) {
return runAsExitNode
}
if (data.UsingExitNode) {
// TODO(sonia): also use online status
const node = { ...data.UsingExitNode }
if (node.Location) {
// For mullvad nodes, use location as name.
node.Name = `${node.Location.Country}: ${node.Location.City}`
} else {
// Otherwise use node name w/o DNS suffix.
node.Name = trimDNSSuffix(node.Name, data.TailnetName)
}
return node
}
return noExitNode
}
function ExitNodeSelectorInner({
node,
selected,
onSelect,
}: {
node: NodeData
selected: ExitNode
onSelect: (node: ExitNode) => void
}) {
const [filter, setFilter] = useState<string>("")
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
const listRef = useRef<HTMLDivElement>(null)
const hasNodes = useMemo(
() => exitNodes.find((n) => n.nodes.length > 0),
[exitNodes]
)
return (
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
<SearchInput
name="exit-node-search"
inputClassName="w-full px-4 py-2"
autoCorrect="off"
autoComplete="off"
autoCapitalize="off"
placeholder="Search exit nodes…"
value={filter}
onChange={(e) => {
// Jump list to top when search value changes.
listRef.current?.scrollTo(0, 0)
setFilter(e.target.value)
}}
/>
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
<div
ref={listRef}
className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll"
>
{hasNodes ? (
exitNodes.map(
(group) =>
group.nodes.length > 0 && (
<div
key={group.id}
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
>
{group.name && (
<div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
{group.name}
</div>
)}
{group.nodes.map((n) => (
<ExitNodeSelectorItem
key={`${n.ID}-${n.Name}`}
node={n}
onSelect={() => onSelect(n)}
isSelected={selected.ID === n.ID}
/>
))}
</div>
)
)
) : (
<div className="text-center truncate text-gray-500 p-5">
{filter
? `No exit nodes matching “${filter}`
: "No exit nodes available"}
</div>
)}
</div>
</div>
)
}
function ExitNodeSelectorItem({
node,
isSelected,
onSelect,
}: {
node: ExitNode
isSelected: boolean
onSelect: () => void
}) {
return (
<button
key={node.ID}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={onSelect}
>
<div>
{node.Location && (
<>
<CountryFlag code={node.Location.CountryCode} />{" "}
</>
)}
<span className="leading-snug">{node.Name}</span>
</div>
{isSelected && <Check />}
</button>
)
}
function CountryFlag({ code }: { code: string }) {
return (
<>{countryFlags[code.toLowerCase()]}</> || (
<span className="font-medium text-gray-500 text-xs">
{code.toUpperCase()}
</span>
)
)
}
const countryFlags: { [countryCode: string]: string } = {
ad: "🇦🇩",
ae: "🇦🇪",
af: "🇦🇫",
ag: "🇦🇬",
ai: "🇦🇮",
al: "🇦🇱",
am: "🇦🇲",
ao: "🇦🇴",
aq: "🇦🇶",
ar: "🇦🇷",
as: "🇦🇸",
at: "🇦🇹",
au: "🇦🇺",
aw: "🇦🇼",
ax: "🇦🇽",
az: "🇦🇿",
ba: "🇧🇦",
bb: "🇧🇧",
bd: "🇧🇩",
be: "🇧🇪",
bf: "🇧🇫",
bg: "🇧🇬",
bh: "🇧🇭",
bi: "🇧🇮",
bj: "🇧🇯",
bl: "🇧🇱",
bm: "🇧🇲",
bn: "🇧🇳",
bo: "🇧🇴",
bq: "🇧🇶",
br: "🇧🇷",
bs: "🇧🇸",
bt: "🇧🇹",
bv: "🇧🇻",
bw: "🇧🇼",
by: "🇧🇾",
bz: "🇧🇿",
ca: "🇨🇦",
cc: "🇨🇨",
cd: "🇨🇩",
cf: "🇨🇫",
cg: "🇨🇬",
ch: "🇨🇭",
ci: "🇨🇮",
ck: "🇨🇰",
cl: "🇨🇱",
cm: "🇨🇲",
cn: "🇨🇳",
co: "🇨🇴",
cr: "🇨🇷",
cu: "🇨🇺",
cv: "🇨🇻",
cw: "🇨🇼",
cx: "🇨🇽",
cy: "🇨🇾",
cz: "🇨🇿",
de: "🇩🇪",
dj: "🇩🇯",
dk: "🇩🇰",
dm: "🇩🇲",
do: "🇩🇴",
dz: "🇩🇿",
ec: "🇪🇨",
ee: "🇪🇪",
eg: "🇪🇬",
eh: "🇪🇭",
er: "🇪🇷",
es: "🇪🇸",
et: "🇪🇹",
eu: "🇪🇺",
fi: "🇫🇮",
fj: "🇫🇯",
fk: "🇫🇰",
fm: "🇫🇲",
fo: "🇫🇴",
fr: "🇫🇷",
ga: "🇬🇦",
gb: "🇬🇧",
gd: "🇬🇩",
ge: "🇬🇪",
gf: "🇬🇫",
gg: "🇬🇬",
gh: "🇬🇭",
gi: "🇬🇮",
gl: "🇬🇱",
gm: "🇬🇲",
gn: "🇬🇳",
gp: "🇬🇵",
gq: "🇬🇶",
gr: "🇬🇷",
gs: "🇬🇸",
gt: "🇬🇹",
gu: "🇬🇺",
gw: "🇬🇼",
gy: "🇬🇾",
hk: "🇭🇰",
hm: "🇭🇲",
hn: "🇭🇳",
hr: "🇭🇷",
ht: "🇭🇹",
hu: "🇭🇺",
id: "🇮🇩",
ie: "🇮🇪",
il: "🇮🇱",
im: "🇮🇲",
in: "🇮🇳",
io: "🇮🇴",
iq: "🇮🇶",
ir: "🇮🇷",
is: "🇮🇸",
it: "🇮🇹",
je: "🇯🇪",
jm: "🇯🇲",
jo: "🇯🇴",
jp: "🇯🇵",
ke: "🇰🇪",
kg: "🇰🇬",
kh: "🇰🇭",
ki: "🇰🇮",
km: "🇰🇲",
kn: "🇰🇳",
kp: "🇰🇵",
kr: "🇰🇷",
kw: "🇰🇼",
ky: "🇰🇾",
kz: "🇰🇿",
la: "🇱🇦",
lb: "🇱🇧",
lc: "🇱🇨",
li: "🇱🇮",
lk: "🇱🇰",
lr: "🇱🇷",
ls: "🇱🇸",
lt: "🇱🇹",
lu: "🇱🇺",
lv: "🇱🇻",
ly: "🇱🇾",
ma: "🇲🇦",
mc: "🇲🇨",
md: "🇲🇩",
me: "🇲🇪",
mf: "🇲🇫",
mg: "🇲🇬",
mh: "🇲🇭",
mk: "🇲🇰",
ml: "🇲🇱",
mm: "🇲🇲",
mn: "🇲🇳",
mo: "🇲🇴",
mp: "🇲🇵",
mq: "🇲🇶",
mr: "🇲🇷",
ms: "🇲🇸",
mt: "🇲🇹",
mu: "🇲🇺",
mv: "🇲🇻",
mw: "🇲🇼",
mx: "🇲🇽",
my: "🇲🇾",
mz: "🇲🇿",
na: "🇳🇦",
nc: "🇳🇨",
ne: "🇳🇪",
nf: "🇳🇫",
ng: "🇳🇬",
ni: "🇳🇮",
nl: "🇳🇱",
no: "🇳🇴",
np: "🇳🇵",
nr: "🇳🇷",
nu: "🇳🇺",
nz: "🇳🇿",
om: "🇴🇲",
pa: "🇵🇦",
pe: "🇵🇪",
pf: "🇵🇫",
pg: "🇵🇬",
ph: "🇵🇭",
pk: "🇵🇰",
pl: "🇵🇱",
pm: "🇵🇲",
pn: "🇵🇳",
pr: "🇵🇷",
ps: "🇵🇸",
pt: "🇵🇹",
pw: "🇵🇼",
py: "🇵🇾",
qa: "🇶🇦",
re: "🇷🇪",
ro: "🇷🇴",
rs: "🇷🇸",
ru: "🇷🇺",
rw: "🇷🇼",
sa: "🇸🇦",
sb: "🇸🇧",
sc: "🇸🇨",
sd: "🇸🇩",
se: "🇸🇪",
sg: "🇸🇬",
sh: "🇸🇭",
si: "🇸🇮",
sj: "🇸🇯",
sk: "🇸🇰",
sl: "🇸🇱",
sm: "🇸🇲",
sn: "🇸🇳",
so: "🇸🇴",
sr: "🇸🇷",
ss: "🇸🇸",
st: "🇸🇹",
sv: "🇸🇻",
sx: "🇸🇽",
sy: "🇸🇾",
sz: "🇸🇿",
tc: "🇹🇨",
td: "🇹🇩",
tf: "🇹🇫",
tg: "🇹🇬",
th: "🇹🇭",
tj: "🇹🇯",
tk: "🇹🇰",
tl: "🇹🇱",
tm: "🇹🇲",
tn: "🇹🇳",
to: "🇹🇴",
tr: "🇹🇷",
tt: "🇹🇹",
tv: "🇹🇻",
tw: "🇹🇼",
tz: "🇹🇿",
ua: "🇺🇦",
ug: "🇺🇬",
um: "🇺🇲",
us: "🇺🇸",
uy: "🇺🇾",
uz: "🇺🇿",
va: "🇻🇦",
vc: "🇻🇨",
ve: "🇻🇪",
vg: "🇻🇬",
vi: "🇻🇮",
vn: "🇻🇳",
vu: "🇻🇺",
wf: "🇼🇫",
ws: "🇼🇸",
xk: "🇽🇰",
ye: "🇾🇪",
yt: "🇾🇹",
za: "🇿🇦",
zm: "🇿🇲",
zw: "🇿🇼",
}

View File

@@ -0,0 +1,298 @@
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
// that (crudely) implement the pre-2023 web client. These are implemented
// purely to ease migration to the new React-based web client, and will
// eventually be completely removed.
export function Header({
data,
refreshData,
updateNode,
}: {
data: NodeData
refreshData: () => void
updateNode: (update: NodeUpdate) => void
}) {
return (
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
<svg
width="26"
height="26"
viewBox="0 0 23 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0 mr-4"
>
<circle
opacity="0.2"
cx="3.4"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="3.4"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="11.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle
opacity="0.2"
cx="19.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="19.5"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
</svg>
<div className="flex items-center justify-end space-x-2 w-2/3">
{data.Profile &&
data.Status !== "NoState" &&
data.Status !== "NeedsLogin" && (
<>
<div className="text-right w-full leading-4">
<h4 className="truncate leading-normal">
{data.Profile.LoginName}
</h4>
<div className="text-xs text-gray-500 text-right">
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="hover:text-gray-700"
>
Switch account
</button>{" "}
|{" "}
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="hover:text-gray-700"
>
Reauthenticate
</button>{" "}
|{" "}
<button
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(refreshData)
.catch((err) => alert("Logout failed: " + err.message))
}
className="hover:text-gray-700"
>
Logout
</button>
</div>
</div>
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{data.Profile.ProfilePicURL ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
</>
)}
</div>
</header>
)
}
export function IP(props: { data: NodeData }) {
const { data } = props
if (!data.IP) {
return null
}
return (
<>
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
<div className="flex items-center min-width-0">
<svg
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<h4 className="font-semibold truncate mr-2">
{data.DeviceName || "Your device"}
</h4>
</div>
<h5>{data.IP}</h5>
</div>
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
{data.IsSynology && (
<>
, DSM{data.DSMVersion}
{data.TUNMode || (
<>
{" "}
(
<a
href="https://tailscale.com/kb/1152/synology-outbound/"
className="link-underline text-gray-600"
target="_blank"
aria-label="Configure outbound synology traffic"
rel="noopener noreferrer"
>
outgoing access not configured
</a>
)
</>
)}
</>
)}
</p>
</>
)
}
export function State({
data,
updateNode,
}: {
data: NodeData
updateNode: (update: NodeUpdate) => void
}) {
switch (data.Status) {
case "NeedsLogin":
case "NoState":
if (data.IP) {
return (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
>
learn more
</a>
.
</p>
</div>
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
)
} else {
return (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
>
tailscale.com
</a>
.
</p>
</div>
<button
onClick={() => updateNode({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Log In
</button>
</>
)
}
case "NeedsMachineAuth":
return (
<div className="mb-4">
This device is authorized, but needs approval from a network admin
before it can connect to the network.
</div>
)
default:
return (
<>
<div className="mb-4">
<p>
You are connected! Access this device over Tailscale using the
device name or IP address above.
</p>
</div>
<button
className={cx("button button-medium mb-4", {
"button-red": data.AdvertiseExitNode,
"button-blue": !data.AdvertiseExitNode,
})}
id="enabled"
onClick={() =>
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
}
>
{data.AdvertiseExitNode
? "Stop advertising Exit Node"
: "Advertise as Exit Node"}
</button>
</>
)
}
}
export function Footer(props: { licensesURL: string; className?: string }) {
return (
<footer
className={cx("container max-w-lg mx-auto text-center", props.className)}
>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={props.licensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

View File

@@ -1,197 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
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/hooks/node-data"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"
export default function LoginToggle({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
const [open, setOpen] = useState<boolean>(false)
return (
<Popover
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
content={
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
}
side="bottom"
align="end"
open={open}
onOpenChange={setOpen}
asChild
>
{!auth.canManageNode ? (
<button
className={cx(
"pl-3 py-1 bg-zinc-800 rounded-full flex justify-start items-center",
{ "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 items-center inline-flex",
{
"bg-transparent": !open,
"bg-neutral-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div>
)}
</Popover>
)
}
function LoginPopoverContent({
node,
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => Promise<void>
}) {
/**
* 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 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.IP}:5252/ok`, { mode: "no-cors" })
.then(() => {
setIsRunningCheck(false)
setCanConnectOverTS(true)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IP])
/**
* 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) {
newSession()
} else {
// Must be connected over Tailscale to log in.
// If not already connected, reroute to the Tailscale IP
// before sending user through check mode.
window.location.href = `http://${node.IP}:5252/?check=now`
}
}, [node.IP, auth.viewerIdentity, newSession])
return (
<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 || auth.authNeeded === AuthType.tailscale ? (
<>
<p className="text-neutral-500 text-xs">
{auth.viewerIdentity ? (
<>
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</>
) : (
<>
You can see most of this device's details. To make changes,
you need to sign in.
</>
)}
</p>
<button
className={cx(
"w-full px-3 py-2 bg-indigo-500 rounded shadow text-center text-white text-sm font-medium mt-2",
{
"mb-2": auth.viewerIdentity,
"cursor-not-allowed": !canConnectOverTS,
}
)}
onClick={handleSignInClick}
// TODO: add some helper info when disabled
// due to needing to connect to TS
disabled={!canConnectOverTS}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
</button>
</>
) : (
<p className="text-neutral-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.
</p>
))}
{auth.viewerIdentity && (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-neutral-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

@@ -1,62 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import { VersionInfo } from "src/hooks/self-update"
import { Link } from "wouter"
export function UpdateAvailableNotification({
details,
}: {
details: VersionInfo
}) {
return (
<div className="card">
<h2 className="mb-2">
Update available{" "}
{details.LatestVersion && `(v${details.LatestVersion})`}
</h2>
<p className="text-sm mb-1 mt-1">
{details.LatestVersion
? `Version ${details.LatestVersion}`
: "A new update"}{" "}
is now available. <ChangelogText version={details.LatestVersion} />
</p>
<Link
className="button button-blue mt-3 text-sm inline-block"
to="/update"
>
Update now
</Link>
</div>
)
}
// isStableTrack takes a Tailscale version string
// of form X.Y.Z (or vX.Y.Z) and returns whether
// it is a stable release (even value of Y)
// or unstable (odd value of Y).
// eg. isStableTrack("1.48.0") === true
// eg. isStableTrack("1.49.112") === false
function isStableTrack(ver: string): boolean {
const middle = ver.split(".")[1]
if (middle && Number(middle) % 2 === 0) {
return true
}
return false
}
export function ChangelogText({ version }: { version?: string }) {
if (!version || !isStableTrack(version)) {
return null
}
return (
<>
Check out the{" "}
<a href="https://tailscale.com/changelog/" className="link">
release notes
</a>{" "}
to find out what's new!
</>
)
}

View File

@@ -1,136 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components"
import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data"
import { useLocation } from "wouter"
export default function DeviceDetailsView({
readonly,
node,
}: {
readonly: boolean
node: NodeData
}) {
const [, setLocation] = useLocation()
return (
<>
<h1 className="mb-10">Device details</h1>
<div className="flex flex-col gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1>{node.DeviceName}</h1>
<div
className={cx("w-2.5 h-2.5 rounded-full", {
"bg-emerald-500": node.Status === "Running",
"bg-gray-300": node.Status !== "Running",
})}
/>
</div>
<button
className={cx(
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium",
{ "cursor-not-allowed": readonly }
)}
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}
disabled={readonly}
>
Disconnect
</button>
</div>
</div>
{node.ClientVersion &&
!node.ClientVersion.RunningLatest &&
!readonly && (
<UpdateAvailableNotification details={node.ClientVersion} />
)}
<div className="card">
<h2 className="mb-2">General</h2>
<table>
<tbody>
<tr className="flex">
<td>Managed by</td>
<td className="flex gap-1 flex-wrap">
{node.IsTagged
? node.Tags.map((t) => <ACLTag key={t} tag={t} />)
: node.Profile?.DisplayName}
</td>
</tr>
<tr>
<td>Machine name</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>OS</td>
<td>{node.OS}</td>
</tr>
<tr>
<td>ID</td>
<td>{node.ID}</td>
</tr>
<tr>
<td>Tailscale version</td>
<td>{node.IPNVersion}</td>
</tr>
<tr>
<td>Key expiry</td>
<td>
{node.KeyExpired
? "Expired"
: // TODO: present as relative expiry (e.g. "5 months from now")
new Date(node.KeyExpiry).toLocaleString()}
</td>
</tr>
</tbody>
</table>
</div>
<div className="card">
<h2 className="mb-2">Addresses</h2>
<table>
<tbody>
<tr>
<td>Tailscale IPv4</td>
<td>{node.IP}</td>
</tr>
<tr>
<td>Tailscale IPv6</td>
<td>{node.IPv6}</td>
</tr>
<tr>
<td>Short domain</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>Full domain</td>
<td>
{node.DeviceName}.{node.TailnetName}
</td>
</tr>
</tbody>
</table>
</div>
<Control.AdminContainer
className="text-neutral-500 text-sm leading-tight text-center"
node={node}
>
Want even more details? Visit{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
this devices page
</Control.AdminLink>{" "}
in the admin console.
</Control.AdminContainer>
</div>
</>
)
}

View File

@@ -1,127 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import { Link } from "wouter"
export default function HomeView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
return (
<div className="mb-12 w-full">
<h2 className="mb-3">This device</h2>
<div className="-mx-5 card mb-9">
<div className="flex justify-between items-center text-lg mb-5">
<div className="flex items-center">
<ConnectedDeviceIcon />
<div className="ml-3">
<h1>{node.DeviceName}</h1>
{/* TODO(sonia): display actual status */}
<p className="text-neutral-500 text-sm">Connected</p>
</div>
</div>
<p className="text-neutral-800 text-lg leading-[25.20px]">
{node.IP}
</p>
</div>
<ExitNodeSelector
className="mb-5"
node={node}
nodeUpdaters={nodeUpdaters}
disabled={readonly}
/>
<Link
className="text-indigo-500 font-medium leading-snug"
to="/details"
>
View device details &rarr;
</Link>
</div>
<h2 className="mb-3">Settings</h2>
<SettingsCard
link="/subnets"
className="mb-3"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
/>
<SettingsCard
link="/ssh"
className="mb-3"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
}
: undefined
}
/>
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
link="/serve"
title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> */}
</div>
)
}
function SettingsCard({
title,
link,
body,
badge,
className,
}: {
title: string
link: string
body: string
badge?: {
text: string
icon?: JSX.Element
}
className?: string
}) {
return (
<Link
to={link}
className={cx(
"-mx-5 card flex justify-between items-center cursor-pointer",
className
)}
>
<div>
<div className="flex gap-2">
<p className="text-neutral-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-neutral-500 text-xs font-medium">
{badge.text}
</div>
</div>
)}
</div>
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
</Link>
)
}

View File

@@ -1,153 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useCallback, useState } from "react"
import { apiFetch } from "src/api"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/hooks/node-data"
import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input"
/**
* LoginView is rendered when the client is not authenticated
* to a tailnet.
*/
export default function LoginView({
data,
refreshData,
}: {
data: NodeData
refreshData: () => void
}) {
const [controlURL, setControlURL] = useState<string>("")
const [authKey, setAuthKey] = useState<string>("")
const login = useCallback(
(opt: TailscaleUpOptions) => {
tailscaleUp(opt).then(refreshData)
},
[refreshData]
)
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<TailscaleIcon className="my-2 mb-8" />
{data.Status === "Stopped" ? (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Connect</h3>
<p className="text-gray-700">
Your device is disconnected from Tailscale.
</p>
</div>
<button
onClick={() => login({})}
className="button button-blue w-full mb-4"
>
Connect to Tailscale
</button>
</>
) : data.IP ? (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
rel="noreferrer"
>
learn more
</a>
.
</p>
</div>
<button
onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Reauthenticate
</button>
</>
) : (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
rel="noreferrer"
>
tailscale.com
</a>
.
</p>
</div>
<button
onClick={() =>
login({
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
})
}
className="button button-blue w-full mb-4"
>
Log In
</button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">
Connect with a pre-authenticated key.{" "}
<a
href="https://tailscale.com/kb/1085/auth-keys/"
className="link"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<Input
className="mt-2"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder="tskey-auth-XXX"
/>
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
<p className="text-sm text-gray-500">Base URL of control server.</p>
<Input
className="mt-2"
value={controlURL}
onChange={(e) => setControlURL(e.target.value)}
placeholder="https://login.tailscale.com/"
/>
</Collapsible>
</>
)}
</div>
)
}
type TailscaleUpOptions = {
Reauthenticate?: boolean // force reauthentication
ControlURL?: string
AuthKey?: string
}
function tailscaleUp(options: TailscaleUpOptions) {
return apiFetch("/up", "POST", options)
.then((r) => r.json())
.then((d) => {
d.url && window.open(d.url, "_blank")
})
.catch((e) => {
console.error("Failed to login:", e)
})
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Toggle from "src/ui/toggle"
export default function SSHView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
return (
<>
<h1 className="mb-1">Tailscale SSH server</h1>
<p className="description mb-10">
Run a Tailscale SSH server on this device and allow other devices in
your tailnet to SSH into it.{" "}
<a
href="https://tailscale.com/kb/1193/tailscale-ssh/"
className="text-indigo-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
<Toggle
checked={node.RunningSSHServer}
onChange={() =>
nodeUpdaters.patchPrefs({
RunSSHSet: true,
RunSSH: !node.RunningSSHServer,
})
}
disabled={readonly}
/>
<div className="text-black text-sm font-medium leading-tight">
Run Tailscale SSH server
</div>
</div>
<Control.AdminContainer
className="text-neutral-500 text-sm leading-tight"
node={node}
>
Remember to make sure that the{" "}
<Control.AdminLink node={node} path="/acls">
tailnet policy file
</Control.AdminLink>{" "}
allows other devices to SSH into this device.
</Control.AdminContainer>
</>
)
}

View File

@@ -1,145 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useMemo, useState } from "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, NodeUpdaters } from "src/hooks/node-data"
import Button from "src/ui/button"
import Input from "src/ui/input"
export default function SubnetRouterView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
const advertisedRoutes = useMemo(
() => node.AdvertisedRoutes || [],
[node.AdvertisedRoutes]
)
const [inputOpen, setInputOpen] = useState<boolean>(
advertisedRoutes.length === 0 && !readonly
)
const [inputText, setInputText] = useState<string>("")
return (
<>
<h1 className="mb-1">Subnet router</h1>
<p className="description mb-5">
Add devices to your tailnet without installing Tailscale.{" "}
<a
href="https://tailscale.com/kb/1019/subnets/"
className="text-indigo-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
{inputOpen ? (
<div className="-mx-5 card shadow">
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
<Input
type="text"
className="text-sm"
placeholder="192.168.0.0/24"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<p className="my-2 h-6 text-neutral-500 text-sm leading-tight">
Add multiple routes by providing a comma-separated list.
</p>
<Button
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(() => {
setInputText("")
setInputOpen(false)
})
}
disabled={readonly || !inputText}
>
Advertise routes
</Button>
</div>
) : (
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
<Plus />
Advertise new route
</Button>
)}
<div className="-mx-5 mt-10">
{advertisedRoutes.length > 0 ? (
<>
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200">
{advertisedRoutes.map((r) => (
<div
className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0"
key={r.Route}
>
<div className="text-neutral-800 leading-snug">{r.Route}</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
{r.Approved ? (
<CheckCircle className="w-4 h-4" />
) : (
<Clock className="w-4 h-4" />
)}
{r.Approved ? (
<div className="text-emerald-800 text-sm leading-tight">
Approved
</div>
) : (
<div className="text-neutral-500 text-sm leading-tight">
Pending approval
</div>
)}
</div>
<Button
intent="secondary"
className="text-sm font-medium"
onClick={() =>
nodeUpdaters.postSubnetRoutes(
advertisedRoutes
.map((it) => it.Route)
.filter((it) => it !== r.Route)
)
}
disabled={readonly}
>
Stop advertising
</Button>
</div>
</div>
))}
</div>
<Control.AdminContainer
className="mt-3 w-full text-center text-neutral-500 text-sm leading-tight"
node={node}
>
To approve routes, in the admin console go to{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
the machines route settings
</Control.AdminLink>
.
</Control.AdminContainer>
</>
) : (
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-neutral-500">
Not advertising any routes
</div>
)}
</div>
</>
)
}

View File

@@ -1,93 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "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,
VersionInfo,
} from "src/hooks/self-update"
import Spinner from "src/ui/spinner"
import { Link } from "wouter"
/**
* UpdatingView is rendered when the user initiates a Tailscale update, and
* the update is in-progress, failed, or completed.
*/
export function UpdatingView({
versionInfo,
currentVersion,
}: {
versionInfo?: VersionInfo
currentVersion: string
}) {
const { updateState, updateLog } = useInstallUpdate(
currentVersion,
versionInfo
)
return (
<>
<div className="flex-1 flex flex-col justify-center items-center text-center mt-56">
{updateState === UpdateState.InProgress ? (
<>
<Spinner size="sm" className="text-gray-400" />
<h1 className="text-2xl m-3">Update in progress</h1>
<p className="text-gray-400">
The update shouldn't take more than a couple of minutes. Once it's
completed, you will be asked to log in again.
</p>
</>
) : updateState === UpdateState.Complete ? (
<>
<CheckCircleIcon />
<h1 className="text-2xl m-3">Update complete!</h1>
<p className="text-gray-400">
You updated Tailscale
{versionInfo && versionInfo.LatestVersion
? ` to ${versionInfo.LatestVersion}`
: null}
. <ChangelogText version={versionInfo?.LatestVersion} />
</p>
<Link className="button button-blue text-sm m-3" to="/">
Log in to access
</Link>
</>
) : updateState === UpdateState.UpToDate ? (
<>
<CheckCircleIcon />
<h1 className="text-2xl m-3">Up to date!</h1>
<p className="text-gray-400">
You are already running Tailscale {currentVersion}, which is the
newest version available.
</p>
<Link className="button button-blue text-sm m-3" to="/">
Return
</Link>
</>
) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
<>
<XCircleIcon />
<h1 className="text-2xl m-3">Update failed</h1>
<p className="text-gray-400">
Update
{versionInfo && versionInfo.LatestVersion
? ` to ${versionInfo.LatestVersion}`
: null}{" "}
failed.
</p>
<Link className="button button-blue text-sm m-3" to="/">
Return
</Link>
</>
)}
<pre className="h-64 overflow-scroll m-3">
<code>{updateLog}</code>
</pre>
</div>
</>
)
}

View File

@@ -1,87 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
export type AuthResponse = {
authNeeded?: AuthType
canManageNode: boolean
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
}
}
// useAuth reports and refreshes Tailscale auth status
// for the web client.
export default function useAuth() {
const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(true)
const loadAuth = useCallback(() => {
setLoading(true)
return apiFetch("/auth", "GET")
.then((r) => r.json())
.then((d) => {
setData(d)
switch ((d as AuthResponse).authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setLoading(false)
})
break
default:
setLoading(false)
}
return d
})
.catch((error) => {
setLoading(false)
console.error(error)
})
}, [])
const newSession = useCallback(() => {
return apiFetch("/auth/session/new", "GET")
.then((r) => r.json())
.then((d) => {
if (d.authUrl) {
window.open(d.authUrl, "_blank")
return apiFetch("/auth/session/wait", "GET")
}
})
.then(() => loadAuth())
.catch((error) => {
console.error(error)
})
}, [loadAuth])
useEffect(() => {
loadAuth().then((d) => {
if (
!d.canManageNode &&
new URLSearchParams(window.location.search).get("check") === "now"
) {
newSession()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return {
data,
loading,
newSession,
}
}

View File

@@ -1,204 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useEffect, useMemo, useState } from "react"
import { apiFetch } from "src/api"
export type ExitNode = {
ID: string
Name: string
Location?: ExitNodeLocation
Online?: boolean
}
type ExitNodeLocation = {
Country: string
CountryCode: CountryCode
City: string
CityCode: CityCode
Priority: number
}
type CountryCode = string
type CityCode = string
export type ExitNodeGroup = {
id: string
name?: string
nodes: ExitNode[]
}
export default function useExitNodes(tailnetName: string, filter?: string) {
const [data, setData] = useState<ExitNode[]>([])
useEffect(() => {
apiFetch("/exit-nodes", "GET")
.then((r) => r.json())
.then((r) => setData(r))
.catch((err) => {
alert("Failed operation: " + err.message)
})
}, [])
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
// First going through exit nodes and splitting them into two groups:
// 1. tailnetNodes: exit nodes advertised by tailnet's own nodes
// 2. locationNodes: exit nodes advertised by non-tailnet Mullvad nodes
let tailnetNodes: ExitNode[] = []
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
data?.forEach((n) => {
const loc = n.Location
if (!loc) {
// 2023-11-15: Currently, if the node doesn't have
// location information, it is owned by the tailnet.
// Only Mullvad exit nodes have locations filled.
tailnetNodes.push({
...n,
Name: trimDNSSuffix(n.Name, tailnetName),
})
return
}
const countryNodes =
locationNodes.get(loc.CountryCode) || new Map<CityCode, ExitNode[]>()
const cityNodes = countryNodes.get(loc.CityCode) || []
countryNodes.set(loc.CityCode, [...cityNodes, n])
locationNodes.set(loc.CountryCode, countryNodes)
})
return {
tailnetNodesSorted: tailnetNodes.sort(compareByName),
locationNodesMap: locationNodes,
}
}, [data, tailnetName])
const hasFilter = Boolean(filter)
const mullvadNodesSorted = useMemo(() => {
const nodes: ExitNode[] = []
// addBestMatchNode adds the node with the "higest priority"
// match from a list of exit node `options` to `nodes`.
const addBestMatchNode = (
options: ExitNode[],
name: (l: ExitNodeLocation) => string
) => {
const bestNode = highestPriorityNode(options)
if (!bestNode || !bestNode.Location) {
return // not possible, doing this for type safety
}
nodes.push({
...bestNode,
Name: name(bestNode.Location),
})
}
if (!hasFilter) {
// When nothing is searched, only show a single best-matching
// exit node per-country.
//
// There's too many location-based nodes to display all of them.
locationNodesMap.forEach(
// add one node per country
(countryNodes) =>
addBestMatchNode(flattenMap(countryNodes), (l) => l.Country)
)
} else {
// Otherwise, show the best match on a city-level,
// with a "Country: Best Match" node at top.
//
// i.e. We allow for discovering cities through searching.
locationNodesMap.forEach((countryNodes) => {
countryNodes.forEach(
// add one node per city
(cityNodes) =>
addBestMatchNode(cityNodes, (l) => `${l.Country}: ${l.City}`)
)
// add the "Country: Best Match" node
addBestMatchNode(
flattenMap(countryNodes),
(l) => `${l.Country}: Best Match`
)
})
}
return nodes.sort(compareByName)
}, [hasFilter, locationNodesMap])
// Ordered and filtered grouping of exit nodes.
const exitNodeGroups = useMemo(() => {
const filterLower = !filter ? undefined : filter.toLowerCase()
return [
{ id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] },
{
id: "tailnet",
nodes: filterLower
? tailnetNodesSorted.filter((n) =>
n.Name.toLowerCase().includes(filterLower)
)
: tailnetNodesSorted,
},
{
id: "mullvad",
name: "Mullvad VPN",
nodes: filterLower
? mullvadNodesSorted.filter((n) =>
n.Name.toLowerCase().includes(filterLower)
)
: mullvadNodesSorted,
},
]
}, [tailnetNodesSorted, mullvadNodesSorted, filter])
return { data: exitNodeGroups }
}
// highestPriorityNode finds the highest priority node for use
// (the "best match" node) from a list of exit nodes.
// Nodes with equal priorities are picked between arbitrarily.
function highestPriorityNode(nodes: ExitNode[]): ExitNode | undefined {
return nodes.length === 0
? undefined
: nodes.sort(
(a, b) => (b.Location?.Priority || 0) - (a.Location?.Priority || 0)
)[0]
}
// compareName compares two exit nodes alphabetically by name.
function compareByName(a: ExitNode, b: ExitNode): number {
if (a.Location && b.Location && a.Location.Country === b.Location.Country) {
// Always put "<Country>: Best Match" node at top of country list.
if (a.Name.includes(": Best Match")) {
return -1
} else if (b.Name.includes(": Best Match")) {
return 1
}
}
return a.Name.localeCompare(b.Name)
}
function flattenMap<T, V>(m: Map<T, V[]>): V[] {
return Array.from(m.values()).reduce((prev, curr) => [...prev, ...curr])
}
// trimDNSSuffix trims the tailnet dns name from s, leaving no
// trailing dots.
//
// trimDNSSuffix("hello.ts.net", "ts.net") = "hello"
// trimDNSSuffix("hello", "ts.net") = "hello"
export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
if (s.endsWith(".")) {
s = s.slice(0, -1)
}
if (s.endsWith("." + tailnetDNSName)) {
s = s.replace("." + tailnetDNSName, "")
}
return s
}
export const noExitNode: ExitNode = { ID: "NONE", Name: "None" }
export const runAsExitNode: ExitNode = {
ID: "RUNNING",
Name: "Run as exit node…",
}

View File

@@ -1,48 +1,23 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update"
export type NodeData = {
Profile: UserProfile
Status: NodeState
Status: string
DeviceName: string
OS: string
IP: string
IPv6: string
ID: string
KeyExpiry: string
KeyExpired: boolean
UsingExitNode?: ExitNode
AdvertisingExitNode: boolean
AdvertisedRoutes?: SubnetRoute[]
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
TUNMode: boolean
IsSynology: boolean
DSMVersion: number
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
ClientVersion?: VersionInfo
URLPrefix: string
DomainName: string
TailnetName: string
IsTagged: boolean
Tags: string[]
RunningSSHServer: boolean
ControlAdminURL: string
LicensesURL: string
}
type NodeState =
| "NoState"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running"
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
}
export type UserProfile = {
LoginName: string
@@ -50,45 +25,11 @@ export type UserProfile = {
ProfilePicURL: string
}
export type SubnetRoute = {
Route: string
Approved: boolean
}
/**
* NodeUpdaters provides a set of mutation functions for a node.
*
* These functions handle both making the requested change, as well as
* refreshing the app's node data state upon completion to reflect any
* relevant changes in the UI.
*/
export type NodeUpdaters = {
/**
* patchPrefs updates node preferences.
* Only provided preferences will be updated.
* Similar to running the tailscale set command in the CLI.
*/
patchPrefs: (d: PrefsPATCHData) => Promise<void>
/**
* postExitNode updates the node's status as either using or
* running as an exit node.
*/
postExitNode: (d: ExitNode) => Promise<void>
/**
* postSubnetRoutes updates the node's advertised subnet routes.
*/
postSubnetRoutes: (d: string[]) => Promise<void>
}
type PrefsPATCHData = {
RunSSHSet?: boolean
RunSSH?: boolean
}
type RoutesPOSTData = {
UseExitNode?: string
export type NodeUpdate = {
AdvertiseRoutes?: string
AdvertiseExitNode?: boolean
AdvertiseRoutes?: string[]
Reauthenticate?: boolean
ForceLogout?: boolean
}
// useNodeData returns basic data about the current node.
@@ -108,54 +49,49 @@ export default function useNodeData() {
[setData]
)
const prefsPATCH = useCallback(
(d: PrefsPATCHData) => {
const updateNode = useCallback(
(update: NodeUpdate) => {
// The contents of this function are mostly copied over
// from the legacy client's web.html file.
// It makes all data updates through one API endpoint.
// As we build out the web client in React,
// this endpoint will eventually be deprecated.
if (isPosting || !data) {
return
}
setIsPosting(true)
if (data) {
const optimisticUpdates = data
if (d.RunSSHSet) {
optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH)
}
// Reflect the pref change immediatley on the frontend,
// then make the prefs PATCH. If the request fails,
// data will be updated to it's previous value in
// onComplete below.
setData(optimisticUpdates)
update = {
...update,
// Default to current data value for any unset fields.
AdvertiseRoutes:
update.AdvertiseRoutes !== undefined
? update.AdvertiseRoutes
: data.AdvertiseRoutes,
AdvertiseExitNode:
update.AdvertiseExitNode !== undefined
? update.AdvertiseExitNode
: data.AdvertiseExitNode,
}
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after PATCH finishes
}
return apiFetch("/local/v0/prefs", "PATCH", d)
.then(onComplete)
.catch((err) => {
onComplete()
alert("Failed to update prefs")
throw err
apiFetch("/data", "POST", update, { up: "true" })
.then((r) => r.json())
.then((r) => {
setIsPosting(false)
const err = r["error"]
if (err) {
throw new Error(err)
}
const url = r["url"]
if (url) {
window.open(url, "_blank")
}
refreshData()
})
.catch((err) => alert("Failed operation: " + err.message))
},
[setIsPosting, refreshData, setData, data]
)
const routesPOST = useCallback(
(d: RoutesPOSTData) => {
setIsPosting(true)
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after POST finishes
}
return apiFetch("/routes", "POST", d)
.then(onComplete)
.catch((err) => {
onComplete()
alert("Failed to update routes")
throw err
})
},
[setIsPosting, refreshData]
[data]
)
useEffect(
@@ -174,36 +110,8 @@ export default function useNodeData() {
}
},
// Run once.
[refreshData]
[]
)
const nodeUpdaters: NodeUpdaters = useMemo(
() => ({
patchPrefs: prefsPATCH,
postExitNode: (node) =>
routesPOST({
AdvertiseExitNode: node.ID === runAsExitNode.ID,
UseExitNode:
node.ID === noExitNode.ID || node.ID === runAsExitNode.ID
? undefined
: node.ID,
AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged
}),
postSubnetRoutes: (routes) =>
routesPOST({
AdvertiseRoutes: routes,
AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged
UseExitNode: data?.UsingExitNode?.ID, // unchanged
}),
}),
[
data?.AdvertisingExitNode,
data?.AdvertisedRoutes,
data?.UsingExitNode?.ID,
prefsPATCH,
routesPOST,
]
)
return { data, refreshData, nodeUpdaters, isPosting }
return { data, refreshData, updateNode, isPosting }
}

View File

@@ -1,134 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { apiFetch } from "src/api"
// this type is deserialized from tailcfg.ClientVersion,
// so it should not include fields not included in that type.
export type VersionInfo = {
RunningLatest: boolean
LatestVersion?: string
}
// see ipnstate.UpdateProgress
export type UpdateProgress = {
status: "UpdateFinished" | "UpdateInProgress" | "UpdateFailed"
message: string
version: string
}
export enum UpdateState {
UpToDate,
Available,
InProgress,
Complete,
Failed,
}
// useInstallUpdate initiates and tracks a Tailscale self-update via the LocalAPI,
// and returns state messages showing the progress of the update.
export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
const [updateState, setUpdateState] = useState<UpdateState>(
cv?.RunningLatest ? UpdateState.UpToDate : UpdateState.Available
)
const [updateLog, setUpdateLog] = useState<string>("")
const appendUpdateLog = useCallback(
(msg: string) => {
setUpdateLog(updateLog + msg + "\n")
},
[updateLog, setUpdateLog]
)
useEffect(() => {
if (updateState !== UpdateState.Available) {
// useEffect cleanup function
return () => {}
}
setUpdateState(UpdateState.InProgress)
apiFetch("/local/v0/update/install", "POST").catch((err) => {
console.error(err)
setUpdateState(UpdateState.Failed)
})
let tsAwayForPolls = 0
let updateMessagesRead = 0
let timer = 0
function poll() {
apiFetch("/local/v0/update/progress", "GET")
.then((res) => res.json())
.then((res: UpdateProgress[]) => {
// res contains a list of UpdateProgresses that is strictly increasing
// in size, so updateMessagesRead keeps track (across calls of poll())
// of how many of those we have already read. This is why it is not
// initialized to zero here and we don't just use res.forEach()
for (; updateMessagesRead < res.length; ++updateMessagesRead) {
const up = res[updateMessagesRead]
if (up.status === "UpdateFailed") {
setUpdateState(UpdateState.Failed)
if (up.message) appendUpdateLog("ERROR: " + up.message)
return
}
if (up.status === "UpdateFinished") {
// if update finished and tailscaled did not go away (ie. did not restart),
// then the version being the same might not be an error, it might just require
// the user to restart Tailscale manually (this is required in some cases in the
// clientupdate package).
if (up.version === currentVersion && tsAwayForPolls > 0) {
setUpdateState(UpdateState.Failed)
appendUpdateLog(
"ERROR: Update failed, still running Tailscale " + up.version
)
if (up.message) appendUpdateLog("ERROR: " + up.message)
} else {
setUpdateState(UpdateState.Complete)
if (up.message) appendUpdateLog("INFO: " + up.message)
}
return
}
setUpdateState(UpdateState.InProgress)
if (up.message) appendUpdateLog("INFO: " + up.message)
}
// If we have gone through the entire loop without returning out of the function,
// the update is still in progress. So we want to poll again for further status
// updates.
timer = setTimeout(poll, 1000)
})
.catch((err) => {
++tsAwayForPolls
if (tsAwayForPolls >= 5 * 60) {
setUpdateState(UpdateState.Failed)
appendUpdateLog(
"ERROR: tailscaled went away but did not come back!"
)
appendUpdateLog("ERROR: last error received:")
appendUpdateLog(err.toString())
} else {
timer = setTimeout(poll, 1000)
}
})
}
poll()
// useEffect cleanup function
return () => {
if (timer) clearTimeout(timer)
timer = 0
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return !cv
? { updateState: UpdateState.UpToDate, updateLog: "" }
: { updateState, updateLog }
}

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -2,192 +2,6 @@
@tailwind components;
@tailwind utilities;
@layer base {
@font-face {
font-family: "Inter";
font-weight: 100 900;
font-style: normal;
font-display: swap;
src: url("./assets/fonts/Inter.var.latin.woff2") format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
U+02BB-02BC, U+2000-206F, U+2122, U+2190-2199, U+2212, U+2215, U+FEFF,
U+FFFD, U+E06B-E080, U+02E2, U+02E2, U+02B0, U+1D34, U+1D57, U+1D40,
U+207F, U+1D3A, U+1D48, U+1D30, U+02B3, U+1D3F;
}
h1 {
@apply text-neutral-800 text-[22px] font-medium leading-[30.80px];
}
h2 {
@apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide;
}
}
@layer components {
.card {
@apply p-5 bg-white rounded-lg border border-gray-200;
}
.card h1 {
@apply text-neutral-800 text-lg font-medium leading-snug;
}
.card h2 {
@apply text-neutral-500 text-xs font-semibold uppercase tracking-wide;
}
.card tbody {
@apply flex flex-col gap-2;
}
.card td:first-child {
@apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
}
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;
}
.description {
@apply text-neutral-500 leading-snug;
}
/**
* .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements.
* You can use the -large and -small modifiers for size variants.
*/
.toggle {
@apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer;
transition: background-color 200ms ease-in-out;
}
.toggle:disabled {
@apply bg-neutral-200;
@apply cursor-not-allowed;
}
.toggle:checked {
@apply bg-indigo-500;
}
.toggle:checked:disabled {
@apply bg-indigo-300;
}
.toggle:focus {
@apply outline-none ring;
}
.toggle::after {
@apply absolute bg-white rounded-full will-change-[width];
@apply w-3.5 h-3.5 m-[0.1875rem] translate-x-0;
content: " ";
transition: width 200ms ease, transform 200ms ease;
}
.toggle:checked::after {
@apply translate-x-5;
}
.toggle:checked:disabled::after {
@apply bg-indigo-50;
}
.toggle:enabled:active::after {
@apply w-[1.125rem];
}
.toggle:checked:enabled:active::after {
@apply w-[1.125rem] translate-x-3.5;
}
.toggle-large {
@apply w-12 h-6;
}
.toggle-large::after {
@apply m-1 w-4 h-4;
}
.toggle-large:checked::after {
@apply translate-x-6;
}
.toggle-large:enabled:active::after {
@apply w-6;
}
.toggle-large:checked:enabled:active::after {
@apply w-6 translate-x-4;
}
.toggle-small {
@apply w-6 h-3;
}
.toggle-small:focus {
/**
* We disable ring for .toggle-small because it is a
* small, inline element.
*/
@apply outline-none shadow-none;
}
.toggle-small::after {
@apply w-2 h-2 m-0.5;
}
.toggle-small:checked::after {
@apply translate-x-3;
}
.toggle-small:enabled:active::after {
@apply w-[0.675rem];
}
.toggle-small:checked:enabled:active::after {
@apply w-[0.675rem] translate-x-[0.55rem];
}
/**
* .input defines default text input field styling. These styles should
* correspond to .button, sharing a similar height and rounding, since .input
* and .button are commonly used together.
*/
.input,
.input-wrapper {
@apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors w-full h-input;
}
.input {
@apply px-3;
}
.input::placeholder,
.input-wrapper::placeholder {
@apply text-gray-400;
}
.input:disabled,
.input-wrapper:disabled {
@apply border-gray-300;
@apply bg-gray-0;
@apply cursor-not-allowed;
}
.input:focus,
.input-wrapper:focus-within {
@apply outline-none ring border-gray-400;
}
.input-error {
@apply border-red-200;
}
}
@layer utilities {
.h-input {
@apply h-[2.375rem];
}
}
/**
* Non-Tailwind styles begin here.
*/
@@ -314,23 +128,3 @@ html {
background-color: #b22d30;
border-color: #b22d30;
}
/**
* .spinner creates a circular animated spinner, most often used to indicate a
* loading state. The .spinner element must define a width, height, and
* border-width for the spinner to apply.
*/
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinner {
@apply border-transparent border-t-current border-l-current rounded-full;
animation: spin 700ms linear infinite;
}

View File

@@ -1,13 +1,3 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Preserved js license comment for web client app.
/**
* @license
* Copyright (c) Tailscale Inc & AUTHORS
* SPDX-License-Identifier: BSD-3-Clause
*/
import React from "react"
import { createRoot } from "react-dom/client"
import App from "src/components/app"

View File

@@ -1,51 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { HTMLAttributes } from "react"
export type BadgeColor =
| "blue"
| "green"
| "red"
| "orange"
| "yellow"
| "gray"
| "outline"
type Props = {
variant: "tag" | "status"
color: BadgeColor
} & HTMLAttributes<HTMLDivElement>
export default function Badge(props: Props) {
const { className, color, variant, ...rest } = props
return (
<div
className={cx(
"inline-flex items-center align-middle justify-center font-medium",
{
"border border-gray-200 bg-gray-200 text-gray-600": color === "gray",
"border border-green-50 bg-green-50 text-green-600":
color === "green",
"border border-blue-50 bg-blue-50 text-blue-600": color === "blue",
"border border-orange-50 bg-orange-50 text-orange-600":
color === "orange",
"border border-yellow-50 bg-yellow-50 text-yellow-600":
color === "yellow",
"border border-red-50 bg-red-50 text-red-600": color === "red",
"border border-gray-300 bg-white": color === "outline",
"rounded-full px-2 py-1 leading-none": variant === "status",
"rounded-sm px-1": variant === "tag",
},
className
)}
{...rest}
/>
)
}
Badge.defaultProps = {
color: "gray",
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { ButtonHTMLAttributes } from "react"
type Props = {
intent?: "primary" | "secondary"
} & ButtonHTMLAttributes<HTMLButtonElement>
export default function Button(props: Props) {
const { intent = "primary", className, disabled, children, ...rest } = props
return (
<button
className={cx(
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium",
{
"bg-indigo-500 text-white": intent === "primary" && !disabled,
"bg-indigo-400 text-indigo-200": intent === "primary" && disabled,
"bg-stone-50 shadow border border-stone-200 text-neutral-800":
intent === "secondary",
"cursor-not-allowed": disabled,
},
className
)}
{...rest}
disabled={disabled}
>
{children}
</button>
)
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import * as Primitive from "@radix-ui/react-collapsible"
import React, { useState } from "react"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
type CollapsibleProps = {
trigger?: string
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export default function Collapsible(props: CollapsibleProps) {
const { children, trigger, onOpenChange } = props
const [open, setOpen] = useState(props.open)
return (
<Primitive.Root
open={open}
onOpenChange={(open) => {
setOpen(open)
onOpenChange?.(open)
}}
>
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
</span>
{trigger}
</Primitive.Trigger>
<Primitive.Content className="mt-2">{children}</Primitive.Content>
</Primitive.Root>
)
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { InputHTMLAttributes } from "react"
type Props = {
className?: string
inputClassName?: string
error?: boolean
suffix?: JSX.Element
} & InputHTMLAttributes<HTMLInputElement>
// Input is styled in a way that only works for text inputs.
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const {
className,
inputClassName,
error,
prefix,
suffix,
disabled,
...rest
} = props
return (
<div className={cx("relative", className)}>
<input
ref={ref}
className={cx("input z-10", inputClassName, {
"input-error": error,
})}
disabled={disabled}
{...rest}
/>
{suffix ? (
<div className="bg-white top-1 bottom-1 right-1 rounded-r-md absolute flex items-center">
{suffix}
</div>
) : null}
</div>
)
})
export default Input

View File

@@ -1,109 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import * as PopoverPrimitive from "@radix-ui/react-popover"
import cx from "classnames"
import React, { ReactNode } from "react"
type Props = {
className?: string
content: ReactNode
children: ReactNode
/**
* asChild renders the trigger element without wrapping it in a button. Use
* this when you want to use a `button` element as the trigger.
*/
asChild?: boolean
/**
* side is the side of the direction from the target element to render the
* popover.
*/
side?: "top" | "bottom" | "left" | "right"
/**
* sideOffset is how far from a give side to render the popover.
*/
sideOffset?: number
/**
* align is how to align the popover with the target element.
*/
align?: "start" | "center" | "end"
/**
* alignOffset is how far off of the alignment point to render the popover.
*/
alignOffset?: number
open?: boolean
onOpenChange?: (open: boolean) => void
}
/**
* Popover is a UI component that allows rendering unique controls in a floating
* popover, attached to a trigger element. It appears on click and manages focus
* on its own behalf.
*
* To use the Popover, pass the content as children, and give it a `trigger`:
*
* <Popover trigger={<span>Open popover</span>}>
* <p>Hello world!</p>
* </Popover>
*
* By default, the toggle is wrapped in an accessible <button> tag. You can
* customize by providing your own button and using the `asChild` prop.
*
* <Popover trigger={<Button>Hello</Button>} asChild>
* <p>Hello world!</p>
* </Popover>
*
* The former style is recommended whenever possible.
*/
export default function Popover(props: Props) {
const {
children,
className,
content,
side,
sideOffset,
align,
alignOffset,
asChild,
open,
onOpenChange,
} = props
return (
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
<PopoverPrimitive.Trigger asChild={asChild}>
{children}
</PopoverPrimitive.Trigger>
<PortalContainerContext.Consumer>
{(portalContainer) => (
<PopoverPrimitive.Portal container={portalContainer}>
<PopoverPrimitive.Content
className={cx(
"origin-radix-popover shadow-popover bg-white rounded-md z-50",
"state-open:animate-scale-in state-closed:animate-scale-out",
className
)}
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
collisionPadding={12}
>
{content}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)}
</PortalContainerContext.Consumer>
</PopoverPrimitive.Root>
)
}
Popover.defaultProps = {
sideOffset: 10,
}
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
undefined
)

View File

@@ -1,41 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"
export default function ProfilePic({
url,
size = "large",
className,
}: {
url?: string
size?: "small" | "medium" | "large"
className?: string
}) {
return (
<div
className={cx(
"relative flex-shrink-0 rounded-full overflow-hidden",
{
"w-5 h-5": size === "small",
"w-[26px] h-[26px]": size === "medium",
"w-8 h-8": size === "large",
},
className
)}
>
{url ? (
<div
className="w-full h-full flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${url})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-full h-full flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
)
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { forwardRef, InputHTMLAttributes } from "react"
import { ReactComponent as Search } from "src/assets/icons/search.svg"
type Props = {
className?: string
inputClassName?: string
} & InputHTMLAttributes<HTMLInputElement>
/**
* SearchInput is a standard input with a search icon.
*/
const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
const { className, inputClassName, ...rest } = props
return (
<div className={cx("relative", className)}>
<Search className="absolute w-[1.25em] h-full ml-2" />
<input
type="text"
className={cx("input px-8", inputClassName)}
ref={ref}
{...rest}
/>
</div>
)
})
SearchInput.displayName = "SearchInput"
export default SearchInput

View File

@@ -1,32 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { HTMLAttributes } from "react"
type Props = {
className?: string
size: "sm" | "md"
} & HTMLAttributes<HTMLDivElement>
export default function Spinner(props: Props) {
const { className, size, ...rest } = props
return (
<div
className={cx(
"spinner inline-block rounded-full align-middle",
{
"border-2 w-4 h-4": size === "sm",
"border-4 w-8 h-8": size === "md",
},
className
)}
{...rest}
/>
)
}
Spinner.defaultProps = {
size: "md",
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { ChangeEvent } from "react"
type Props = {
id?: string
className?: string
disabled?: boolean
checked: boolean
sizeVariant?: "small" | "medium" | "large"
onChange: (checked: boolean) => void
}
export default function Toggle(props: Props) {
const { className, id, disabled, checked, sizeVariant, onChange } = props
function handleChange(e: ChangeEvent<HTMLInputElement>) {
onChange(e.target.checked)
}
return (
<input
id={id}
type="checkbox"
className={cx(
"toggle",
{
"toggle-large": sizeVariant === "large",
"toggle-small": sizeVariant === "small",
},
className
)}
disabled={disabled}
checked={checked}
onChange={handleChange}
/>
)
}
Toggle.defaultProps = {
sizeVariant: "medium",
}

View File

@@ -7,7 +7,6 @@
package web
import (
"errors"
"fmt"
"net/http"
"os/exec"
@@ -18,42 +17,62 @@ import (
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client.
// If the user is authenticated, but not authorized to use the client, an error is returned.
func authorizeSynology(r *http.Request) (authorized bool, err error) {
if !hasSynoToken(r) {
return false, nil
// It reports true if the request is authorized to continue, and false otherwise.
// authorizeSynology manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeSynology(w http.ResponseWriter, r *http.Request) (ok bool) {
if synoTokenRedirect(w, r) {
return false
}
// authenticate the Synology user
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("auth: %v: %s", err, out)
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
return false
}
user := strings.TrimSpace(string(out))
// check if the user is in the administrators group
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
if err != nil {
return false, err
http.Error(w, err.Error(), http.StatusForbidden)
return false
}
if !isAdmin {
return false, errors.New("not a member of administrators group")
http.Error(w, "not a member of administrators group", http.StatusForbidden)
return false
}
return true, nil
return true
}
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
func hasSynoToken(r *http.Request) bool {
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return true
return false
}
if r.URL.Query().Get("SynoToken") != "" {
return true
return false
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return true
return false
}
return false
// We need a SynoToken for authenticate.cgi.
// So we tell the client to get one.
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
return true
}
const synoTokenRedirectHTML = `<html>
Redirecting with session token...
<script>
fetch("/webman/login.cgi")
.then(r => r.json())
.then(data => {
u = new URL(window.location)
u.searchParams.set("SynoToken", data.SynoToken)
document.location = u
})
</script>
`

View File

@@ -1,46 +1,12 @@
const plugin = require("tailwindcss/plugin")
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
fontFamily: {
sans: [
"Inter",
"-apple-system",
"BlinkMacSystemFont",
"Helvetica",
"Arial",
"sans-serif",
],
mono: [
"SFMono-Regular",
"SFMono Regular",
"Consolas",
"Liberation Mono",
"Menlo",
"Courier",
"monospace",
],
},
fontWeight: {
normal: "400",
medium: "500",
semibold: "600",
bold: "700",
},
extend: {},
},
plugins: [
plugin(function ({ addVariant }) {
addVariant("state-open", [
'&[data-state="open"]',
'[data-state="open"] &',
])
addVariant("state-closed", [
'&[data-state="closed"]',
'[data-state="closed"] &',
])
}),
],
plugins: [],
}

View File

@@ -47,8 +47,14 @@ export default defineConfig({
// This needs to be 127.0.0.1 instead of localhost, because of how our
// Go proxy connects to it.
host: "127.0.0.1",
// If you change the port, be sure to update the proxy in assets.go too.
// If you change the port, be sure to update the proxy in adminhttp.go too.
port: 4000,
// Don't proxy the WebSocket connection used for live reloading by running
// it on a separate port.
hmr: {
protocol: "ws",
port: 4001,
},
},
test: {
exclude: ["**/node_modules/**", "**/dist/**"],

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,12 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
@@ -20,12 +18,10 @@ import (
"github.com/google/go-cmp/cmp"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
func TestQnapAuthnURL(t *testing.T) {
@@ -128,7 +124,7 @@ func TestServeAPI(t *testing.T) {
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)
t.Errorf("wrong status; want=%q, got=%q", tt.wantStatus, gotStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
@@ -154,50 +150,76 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
tags := views.SliceOf([]string{"tag:server"})
tailnetNodes := map[string]*apitype.WhoIsResponse{
userANodeIP: {
Node: &tailcfg.Node{ID: 1, StableID: "1"},
Node: &tailcfg.Node{StableID: "Node1"},
UserProfile: userA,
},
userBNodeIP: {
Node: &tailcfg.Node{ID: 2, StableID: "2"},
Node: &tailcfg.Node{StableID: "Node2"},
UserProfile: userB,
},
taggedNodeIP: {
Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()},
Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()},
},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil)
// Serve a testing localapi handler so we can simulate
// whois responses without a functioning tailnet.
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/whois":
addr := r.URL.Query().Get("addr")
if addr == "" {
t.Fatalf("/whois call missing \"addr\" query")
}
if node := tailnetNodes[addr]; node != nil {
if err := json.NewEncoder(w).Encode(&node); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
}
http.Error(w, "not a node", http.StatusUnauthorized)
return
case "/localapi/v0/status":
status := ipnstate.Status{Self: selfNode}
if err := json.NewEncoder(w).Encode(status); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
default:
// Only the above two endpoints get triggered from getTailscaleBrowserSession.
// No need to mock any of the other localapi endpoint.
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}
})}
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
timeNow: time.Now,
lc: &tailscale.LocalClient{Dial: lal.Dial},
}
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
// Add some browser sessions to cache state.
userASession := &browserSession{
ID: "cookie1",
SrcNode: 1,
SrcNode: "Node1",
SrcUser: userA.ID,
Created: time.Now(),
Authenticated: false, // not yet authenticated
Authenticated: time.Time{}, // not yet authenticated
}
userBSession := &browserSession{
ID: "cookie2",
SrcNode: 2,
SrcNode: "Node2",
SrcUser: userB.ID,
Created: time.Now().Add(-2 * sessionCookieExpiry),
Authenticated: true, // expired
Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired
}
userASessionAuthorized := &browserSession{
ID: "cookie3",
SrcNode: 1,
SrcNode: "Node1",
SrcUser: userA.ID,
Created: time.Now(),
Authenticated: true, // authenticated and not expired
Authenticated: time.Now(), // authenticated and not expired
}
s.browserSessions.Store(userASession.ID, userASession)
s.browserSessions.Store(userBSession.ID, userBSession)
@@ -243,26 +265,11 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
wantError: errNotOwner,
},
{
name: "tagged-remote-source",
name: "tagged-source",
selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
remoteAddr: taggedNodeIP,
wantSession: nil,
wantError: errTaggedRemoteSource,
},
{
name: "tagged-local-source",
selfNode: &ipnstate.PeerStatus{ID: "3"},
remoteAddr: taggedNodeIP, // same node as selfNode
wantSession: nil,
wantError: errTaggedLocalSource,
},
{
name: "not-tagged-local-source",
selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID},
remoteAddr: userANodeIP, // same node as selfNode
cookie: userASession.ID,
wantSession: userASession,
wantError: nil, // should not error
wantError: errTaggedSource,
},
{
name: "has-session",
@@ -305,551 +312,16 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
if tt.cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
}
session, _, err := s.getSession(r)
session, err := s.getTailscaleBrowserSession(r)
if !errors.Is(err, tt.wantError) {
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
}
if diff := cmp.Diff(session, tt.wantSession); diff != "" {
t.Errorf("wrong session; (-got+want):%v", diff)
}
if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized {
if gotIsAuthorized := session.isAuthorized(); gotIsAuthorized != tt.wantIsAuthorized {
t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
}
})
}
}
// TestAuthorizeRequest tests the s.authorizeRequest function.
// 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
func TestAuthorizeRequest(t *testing.T) {
// Create self and remoteNode owned by same user.
// See TestGetTailscaleBrowserSession for tests of
// browser sessions w/ different users.
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
remoteIP := "100.100.100.101"
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
func() *ipnstate.PeerStatus { return self },
nil,
)
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
}
validCookie := "ts-cookie"
s.browserSessions.Store(validCookie, &browserSession{
ID: validCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: time.Now(),
Authenticated: true,
})
tests := []struct {
reqPath string
reqMethod string
wantOkNotOverTailscale bool // simulates req over public internet
wantOkWithoutSession bool // simulates req over TS without valid browser session
wantOkWithSession bool // simulates req over TS with valid browser session
}{{
reqPath: "/api/data",
reqMethod: httpm.GET,
wantOkNotOverTailscale: false,
wantOkWithoutSession: true,
wantOkWithSession: true,
}, {
reqPath: "/api/data",
reqMethod: httpm.POST,
wantOkNotOverTailscale: false,
wantOkWithoutSession: false,
wantOkWithSession: true,
}, {
reqPath: "/api/somethingelse",
reqMethod: httpm.GET,
wantOkNotOverTailscale: false,
wantOkWithoutSession: false,
wantOkWithSession: true,
}, {
reqPath: "/assets/styles.css",
wantOkNotOverTailscale: false,
wantOkWithoutSession: true,
wantOkWithSession: true,
}}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
doAuthorize := func(remoteAddr string, cookie string) bool {
r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
r.RemoteAddr = remoteAddr
if cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
}
w := httptest.NewRecorder()
return s.authorizeRequest(w, r)
}
// Do request from non-Tailscale IP.
if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
}
// Do request from Tailscale IP w/o associated session.
if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
}
// Do request from Tailscale IP w/ associated session.
if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
}
})
}
}
func TestServeAuth(t *testing.T) {
user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)}
self := &ipnstate.PeerStatus{
ID: "self",
UserID: user.ID,
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
}
remoteIP := "100.100.100.101"
remoteNode := &apitype.WhoIsResponse{
Node: &tailcfg.Node{
Name: "nodey",
ID: 1,
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
},
UserProfile: user,
}
vi := &viewerIdentity{
LoginName: user.LoginName,
NodeName: remoteNode.Node.Name,
NodeIP: remoteIP,
ProfilePicURL: user.ProfilePicURL,
}
testControlURL := &defaultControlURL
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t,
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
func() *ipnstate.PeerStatus { return self },
func() *ipn.Prefs {
return &ipn.Prefs{ControlURL: *testControlURL}
},
)
defer localapi.Close()
go localapi.Serve(lal)
timeNow := time.Now()
oneHourAgo := timeNow.Add(-time.Hour)
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: func() time.Time { return timeNow },
newAuthURL: mockNewAuthURL,
waitAuthURL: mockWaitAuthURL,
}
successCookie := "ts-cookie-success"
s.browserSessions.Store(successCookie, &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
})
failureCookie := "ts-cookie-failure"
s.browserSessions.Store(failureCookie, &browserSession{
ID: failureCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathError,
AuthURL: *testControlURL + testAuthPathError,
})
expiredCookie := "ts-cookie-expired"
s.browserSessions.Store(expiredCookie, &browserSession{
ID: expiredCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: sixtyDaysAgo,
AuthID: "/a/old-auth-url",
AuthURL: *testControlURL + "/a/old-auth-url",
})
tests := []struct {
name string
controlURL string // if empty, defaultControlURL is used
cookie string // cookie attached to request
wantNewCookie bool // want new cookie generated during request
wantSession *browserSession // session associated w/ cookie after request
path string
wantStatus int
wantResp any
}{
{
name: "no-session",
path: "/api/auth",
wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
wantNewCookie: false,
wantSession: nil,
},
{
name: "new-session",
path: "/api/auth/session/new",
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID", // gets swapped for newly created ID by test
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath,
Authenticated: false,
},
},
{
name: "query-existing-incomplete-session",
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: false,
},
},
{
name: "existing-session-used",
path: "/api/auth/session/new", // should not create new session
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: false,
},
},
{
name: "transition-to-successful-session",
path: "/api/auth/session/wait",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: nil,
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: true,
},
},
{
name: "query-existing-complete-session",
path: "/api/auth",
cookie: successCookie,
wantStatus: http.StatusOK,
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi},
wantSession: &browserSession{
ID: successCookie,
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: oneHourAgo,
AuthID: testAuthPathSuccess,
AuthURL: *testControlURL + testAuthPathSuccess,
Authenticated: true,
},
},
{
name: "transition-to-failed-session",
path: "/api/auth/session/wait",
cookie: failureCookie,
wantStatus: http.StatusUnauthorized,
wantResp: nil,
wantSession: nil, // session deleted
},
{
name: "failed-session-cleaned-up",
path: "/api/auth/session/new",
cookie: failureCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID",
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath,
Authenticated: false,
},
},
{
name: "expired-cookie-gets-new-session",
path: "/api/auth/session/new",
cookie: expiredCookie,
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID",
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
AuthID: testAuthPath,
AuthURL: *testControlURL + testAuthPath,
Authenticated: false,
},
},
{
name: "control-server-no-check-mode",
controlURL: "http://alternate-server.com/",
path: "/api/auth/session/new",
wantStatus: http.StatusOK,
wantResp: &newSessionAuthResponse{},
wantNewCookie: true,
wantSession: &browserSession{
ID: "GENERATED_ID", // gets swapped for newly created ID by test
SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
Created: timeNow,
Authenticated: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.controlURL != "" {
testControlURL = &tt.controlURL
} else {
testControlURL = &defaultControlURL
}
r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
r.RemoteAddr = remoteIP
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
w := httptest.NewRecorder()
s.serve(w, r)
res := w.Result()
defer res.Body.Close()
// Validate response status/data.
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
}
var gotResp string
if res.StatusCode == http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
gotResp = strings.Trim(string(body), "\n")
}
var wantResp string
if tt.wantResp != nil {
b, _ := json.Marshal(tt.wantResp)
wantResp = string(b)
}
if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
t.Errorf("wrong response; (-got+want):%v", diff)
}
// Validate cookie creation.
sessionID := tt.cookie
var gotCookie bool
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName {
gotCookie = true
sessionID = c.Value
break
}
}
if gotCookie != tt.wantNewCookie {
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
}
// Validate browser session contents.
var gotSesson *browserSession
if s, ok := s.browserSessions.Load(sessionID); ok {
gotSesson = s.(*browserSession)
}
if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
// If requested, swap in the generated session ID before
// comparing got/want.
tt.wantSession.ID = sessionID
}
if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
t.Errorf("wrong session; (-got+want):%v", diff)
}
})
}
}
func TestRequireTailscaleIP(t *testing.T) {
self := &ipnstate.PeerStatus{
TailscaleIPs: []netip.Addr{
netip.MustParseAddr("100.1.2.3"),
netip.MustParseAddr("fd7a:115c::1234"),
},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil)
defer localapi.Close()
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
logf: t.Logf,
}
tests := []struct {
name string
target string
wantHandled bool
wantLocation string
}{
{
name: "localhost",
target: "http://localhost/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv4-no-port",
target: "http://100.1.2.3/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv4-correct-port",
target: "http://100.1.2.3:5252/",
wantHandled: false,
},
{
name: "ipv6-no-port",
target: "http://[fd7a:115c::1234]/",
wantHandled: true,
wantLocation: "http://100.1.2.3:5252/",
},
{
name: "ipv6-correct-port",
target: "http://[fd7a:115c::1234]:5252/",
wantHandled: false,
},
{
name: "quad-100",
target: "http://100.100.100.100/",
wantHandled: false,
},
{
name: "ipv6-service-addr",
target: "http://[fd7a:115c:a1e0::53]/",
wantHandled: false,
},
}
for _, tt := range tests {
t.Run(tt.target, func(t *testing.T) {
s.logf = t.Logf
r := httptest.NewRequest(httpm.GET, tt.target, nil)
w := httptest.NewRecorder()
handled := s.requireTailscaleIP(w, r)
if handled != tt.wantHandled {
t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
}
location := w.Header().Get("Location")
if location != tt.wantLocation {
t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
}
})
}
}
var (
defaultControlURL = "https://controlplane.tailscale.com"
testAuthPath = "/a/12345"
testAuthPathSuccess = "/a/will-succeed"
testAuthPathError = "/a/will-error"
)
// mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet.
//
// self accepts a function that resolves to a self node status,
// so that tests may swap out the /localapi/v0/status response
// as desired.
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs) *http.Server {
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/whois":
addr := r.URL.Query().Get("addr")
if addr == "" {
t.Fatalf("/whois call missing \"addr\" query")
}
if node := whoIs[addr]; node != nil {
writeJSON(w, &node)
return
}
http.Error(w, "not a node", http.StatusUnauthorized)
return
case "/localapi/v0/status":
writeJSON(w, ipnstate.Status{Self: self()})
return
case "/localapi/v0/prefs":
writeJSON(w, prefs())
return
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}
})}
}
func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
// Create new dummy auth URL.
return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil
}
func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
switch id {
case testAuthPathSuccess: // successful auth URL
return &tailcfg.WebClientAuthResponse{Complete: true}, nil
case testAuthPathError: // error auth URL
return nil, errors.New("authenticated as wrong user")
default:
return nil, errors.New("unknown id")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,6 @@ import (
"github.com/google/uuid"
"tailscale.com/clientupdate/distsign"
"tailscale.com/types/logger"
"tailscale.com/util/cmpver"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/version/distro"
@@ -73,12 +72,11 @@ type Arguments struct {
//
// Leaving this empty is the same as using CurrentTrack.
Version string
// AppStore forces a local app store check, even if the current binary was
// not installed via an app store. TODO(cpalmer): Remove this.
AppStore bool
// Logf is a logger for update progress messages.
Logf logger.Logf
// Stdout and Stderr should be used for output instead of os.Stdout and
// os.Stderr.
Stdout io.Writer
Stderr io.Writer
// Confirm is called when a new version is available and should return true
// if this new version should be installed. When Confirm returns false, the
// update is aborted.
@@ -86,10 +84,6 @@ type Arguments struct {
// PkgsAddr is the address of the pkgs server to fetch updates from.
// Defaults to "https://pkgs.tailscale.com".
PkgsAddr string
// ForAutoUpdate should be true when Updater is created in auto-update
// context. When true, NewUpdater returns an error if it cannot be used for
// auto-updates (even if Updater.Update field is non-nil).
ForAutoUpdate bool
}
func (args Arguments) validate() error {
@@ -114,20 +108,10 @@ func NewUpdater(args Arguments) (*Updater, error) {
up := Updater{
Arguments: args,
}
if up.Stdout == nil {
up.Stdout = os.Stdout
}
if up.Stderr == nil {
up.Stderr = os.Stderr
}
var canAutoUpdate bool
up.Update, canAutoUpdate = up.getUpdateFunction()
up.Update = up.getUpdateFunction()
if up.Update == nil {
return nil, errors.ErrUnsupported
}
if args.ForAutoUpdate && !canAutoUpdate {
return nil, errors.ErrUnsupported
}
switch up.Version {
case StableTrack, UnstableTrack:
up.track = up.Version
@@ -152,74 +136,52 @@ func NewUpdater(args Arguments) (*Updater, error) {
type updateFunction func() error
func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
func (up *Updater) getUpdateFunction() updateFunction {
switch runtime.GOOS {
case "windows":
return up.updateWindows, true
return up.updateWindows
case "linux":
switch distro.Get() {
case distro.Synology:
// Synology updates use our own pkgs.tailscale.com instead of the
// Synology Package Center. We should eventually get to a regular
// release cadence with Synology Package Center and use their
// auto-update mechanism.
return up.updateSynology, false
return up.updateSynology
case distro.Debian: // includes Ubuntu
return up.updateDebLike, true
return up.updateDebLike
case distro.Arch:
if up.archPackageInstalled() {
// Arch update func just prints a message about how to update,
// it doesn't support auto-updates.
return up.updateArchLike, false
}
return up.updateLinuxBinary, true
return up.updateArchLike
case distro.Alpine:
return up.updateAlpineLike, true
case distro.Unraid:
return up.updateUnraid, true
case distro.QNAP:
return up.updateQNAP, true
return up.updateAlpineLike
}
switch {
case haveExecutable("pacman"):
if up.archPackageInstalled() {
// Arch update func just prints a message about how to update,
// it doesn't support auto-updates.
return up.updateArchLike, false
}
return up.updateLinuxBinary, true
return up.updateArchLike
case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
// The distro.Debian switch case above should catch most apt-based
// systems, but add this fallback just in case.
return up.updateDebLike, true
return up.updateDebLike
case haveExecutable("dnf"):
return up.updateFedoraLike("dnf"), true
return up.updateFedoraLike("dnf")
case haveExecutable("yum"):
return up.updateFedoraLike("yum"), true
return up.updateFedoraLike("yum")
case haveExecutable("apk"):
return up.updateAlpineLike, true
return up.updateAlpineLike
}
// If nothing matched, fall back to tarball updates.
if up.Update == nil {
return up.updateLinuxBinary, true
return up.updateLinuxBinary
}
case "darwin":
switch {
case version.IsMacAppStore():
// App store update func just opens the store page, it doesn't
// support auto-updates.
return up.updateMacAppStore, false
case version.IsMacSysExt():
// Macsys update func kicks off Sparkle. Auto-updates are done by
// Sparkle.
return up.updateMacSys, false
case !up.Arguments.AppStore && !version.IsSandboxedMacOS():
return nil
case !up.Arguments.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
return up.updateMacSys
default:
return nil, false
return up.updateMacAppStore
}
case "freebsd":
return up.updateFreeBSD, true
return up.updateFreeBSD
}
return nil, false
return nil
}
// Update runs a single update attempt using the platform-specific mechanism.
@@ -239,12 +201,8 @@ func Update(args Arguments) error {
}
func (up *Updater) confirm(ver string) bool {
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)
if version.Short() == ver {
up.Logf("already running %v; no update needed", ver)
return false
}
if up.Confirm != nil {
@@ -259,9 +217,6 @@ func (up *Updater) updateSynology() error {
if up.Version != "" {
return errors.New("installing a specific version on Synology is not supported")
}
if err := requireRoot(); err != nil {
return err
}
// Get the latest version and list of SPKs from pkgs.tailscale.com.
dsmVersion := distro.DSMVersion()
@@ -282,8 +237,10 @@ func (up *Updater) updateSynology() error {
if !up.confirm(latest.SPKsVersion) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-update*", "*.spk"))
// Download the SPK into a temporary directory.
spkDir, err := os.MkdirTemp("", "tailscale-update")
if err != nil {
@@ -299,9 +256,9 @@ func (up *Updater) updateSynology() error {
// connected over tailscale ssh and this parent process dies. Otherwise, if
// you abort synopkg install mid-way, tailscaled is not restarted.
cmd := exec.Command("nohup", "synopkg", "install", spkPath)
// Don't attach cmd.Stdout to Stdout because nohup will redirect that into
// nohup.out file. synopkg doesn't have any progress output anyway, it just
// spits out a JSON result when done.
// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
// into nohup.out file. synopkg doesn't have any progress output anyway, it
// just spits out a JSON result when done.
out, err := cmd.CombinedOutput()
if err != nil {
if dsmVersion == 6 && bytes.Contains(out, []byte("error = [290]")) {
@@ -412,25 +369,17 @@ func (up *Updater) updateDebLike() error {
// we're not updating them:
"-o", "APT::Get::List-Cleanup=0",
)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get update failed: %w; output:\n%s", err, out)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
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`)) {
return fmt.Errorf("apt-get install failed: %w; output:\n%s", err, out)
}
up.Logf("apt-get install failed: %s; output:\n%s", err, out)
up.Logf("running dpkg --configure tailscale")
out, err = exec.Command("dpkg", "--force-confdef,downgrade", "--configure", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("dpkg --configure tailscale failed: %w; output:\n%s", err, out)
}
continue
}
break
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
@@ -493,12 +442,12 @@ func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []
return buf.Bytes(), nil
}
func (up *Updater) archPackageInstalled() bool {
err := exec.Command("pacman", "--query", "tailscale").Run()
return err == nil
}
func (up *Updater) updateArchLike() error {
if err := exec.Command("pacman", "--query", "tailscale").Run(); err != nil && isExitError(err) {
// Tailscale was not installed via pacman, update via tarball download
// instead.
return up.updateLinuxBinary()
}
// Arch maintainer asked us not to implement "tailscale update" or
// auto-updates on Arch-based distros:
// https://github.com/tailscale/tailscale/issues/6995#issuecomment-1687080106
@@ -542,8 +491,8 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
}
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
@@ -613,11 +562,11 @@ func (up *Updater) updateAlpineLike() (err error) {
out, err := exec.Command("apk", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh apk repository indexes: %w, output:\n%s", err, out)
return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
}
out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output:\n%s", err, out)
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
}
ver, err := parseAlpinePackageVersion(out)
if err != nil {
@@ -628,8 +577,8 @@ func (up *Updater) updateAlpineLike() (err error) {
}
cmd := exec.Command("apk", "upgrade", "tailscale")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using apk: %w", err)
}
@@ -659,76 +608,76 @@ func (up *Updater) updateMacSys() error {
}
func (up *Updater) updateMacAppStore() error {
// We can't trigger the update via App Store from the sandboxed app. At
// most, we can open the App Store page for them.
up.Logf("Please use the App Store to update Tailscale.\nConsider enabling Automatic Updates in the App Store Settings, if you haven't already.\nOpening the Tailscale app page...")
out, err := exec.Command("open", "https://apps.apple.com/us/app/tailscale/id1475387142").CombinedOutput()
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
if err != nil {
return fmt.Errorf("can't open the Tailscale page in App Store: %w, output:\n%s", err, string(out))
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
}
const on = "1\n"
if string(out) != on {
up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for update).")
}
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
}
newTailscale := parseSoftwareupdateList(out)
if newTailscale == "" {
up.Logf("no Tailscale update available")
return nil
}
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
if !up.confirm(newTailscaleVer) {
return nil
}
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
}
return nil
}
const (
// winMSIEnv is the environment variable that, if set, is the MSI file for
// the update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and
// tries to overwrite ourselves.
winMSIEnv = "TS_UPDATE_WIN_MSI"
// winExePathEnv is the environment variable that is set along with
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
// install is complete.
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
)
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
// Darwin and returns the matching Tailscale package label. If there is none,
// returns the empty string.
//
// See TestParseSoftwareupdateList for example inputs.
func parseSoftwareupdateList(stdout []byte) string {
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
if len(matches) < 2 {
return ""
}
return string(matches[1])
}
// winMSIEnv is the environment variable that, if set, is the MSI file for the
// update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and tries
// to overwrite ourselves.
const winMSIEnv = "TS_UPDATE_WIN_MSI"
var (
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
launchTailscaleAsWinGUIUser func(string) error // or nil on non-Windows
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
)
func (up *Updater) updateWindows() error {
if msi := os.Getenv(winMSIEnv); msi != "" {
// stdout/stderr from this part of the install could be lost since the
// parent tailscaled is replaced. Create a temp log file to have some
// output to debug with in case update fails.
close, err := up.switchOutputToFile()
if err != nil {
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
} else {
defer close.Close()
}
up.Logf("installing %v ...", msi)
if err := up.installMSI(msi); err != nil {
up.Logf("MSI install failed: %v", err)
return err
}
up.Logf("relaunching tailscale-ipn.exe...")
exePath := os.Getenv(winExePathEnv)
if exePath == "" {
up.Logf("env var %q not passed to installer binary copy", winExePathEnv)
return fmt.Errorf("env var %q not passed to installer binary copy", winExePathEnv)
}
if err := launchTailscaleAsWinGUIUser(exePath); err != nil {
up.Logf("Failed to re-launch tailscale after update: %v", err)
return err
}
up.Logf("success.")
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New(`update must be run as Administrator
you can run the command prompt as Administrator one of these ways:
* right-click cmd.exe, select 'Run as administrator'
* press Windows+x, then press a
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
@@ -737,9 +686,13 @@ you can run the command prompt as Administrator one of these ways:
if arch == "386" {
arch = "x86"
}
if !up.confirm(ver) {
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New("must be run as Administrator")
}
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
msiDir := filepath.Join(tsDir, "MSICache")
@@ -751,7 +704,6 @@ you can run the command prompt as Administrator one of these ways:
if err := os.MkdirAll(msiDir, 0700); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
@@ -765,7 +717,7 @@ you can run the command prompt as Administrator one of these ways:
up.Logf("authenticode verification succeeded")
up.Logf("making tailscale.exe copy to switch to...")
selfOrig, selfCopy, err := makeSelfCopy()
selfCopy, err := makeSelfCopy()
if err != nil {
return err
}
@@ -773,9 +725,9 @@ you can run the command prompt as Administrator one of these ways:
up.Logf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
cmd.Stdout = up.Stderr
cmd.Stderr = up.Stderr
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return err
@@ -786,44 +738,18 @@ you can run the command prompt as Administrator one of these ways:
panic("unreachable")
}
func (up *Updater) switchOutputToFile() (io.Closer, error) {
var logFilePath string
exePath, err := os.Executable()
if err != nil {
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
} else {
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
}
up.Logf("writing update output to %q", logFilePath)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, err
}
up.Logf = func(m string, args ...any) {
fmt.Fprintf(logFile, m+"\n", args...)
}
up.Stdout = logFile
up.Stderr = logFile
return logFile, nil
}
func (up *Updater) installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
// TS_NOLAUNCH: don't automatically launch the app after install.
// We will launch it explicitly as the current GUI user afterwards.
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn", "TS_NOLAUNCH=true")
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
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err == nil {
break
}
up.Logf("Install attempt failed: %v", err)
uninstallVersion := version.Short()
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
@@ -831,8 +757,8 @@ func (up *Updater) installMSI(msi string) error {
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
up.Logf("msiexec uninstall: %v", err)
@@ -840,30 +766,6 @@ func (up *Updater) installMSI(msi string) error {
return err
}
// cleanupOldDownloads removes all files matching glob (see filepath.Glob).
// Only regular files are removed, so the glob must match specific files and
// not directories.
func (up *Updater) cleanupOldDownloads(glob string) {
matches, err := filepath.Glob(glob)
if err != nil {
up.Logf("cleaning up old downloads: %v", err)
return
}
for _, m := range matches {
s, err := os.Lstat(m)
if err != nil {
up.Logf("cleaning up old downloads: %v", err)
continue
}
if !s.Mode().IsRegular() {
continue
}
if err := os.Remove(m); err != nil {
up.Logf("cleaning up old downloads: %v", err)
}
}
}
func msiUUIDForVersion(ver string) string {
arch := runtime.GOARCH
if arch == "386" {
@@ -877,30 +779,30 @@ func msiUUIDForVersion(ver string) string {
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
func makeSelfCopy() (tmpPathExe string, err error) {
selfExe, err := os.Executable()
if err != nil {
return "", "", err
return "", err
}
f, err := os.Open(selfExe)
if err != nil {
return "", "", err
return "", err
}
defer f.Close()
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
if err != nil {
return "", "", err
return "", err
}
if f := markTempFileFunc; f != nil {
if err := f(f2.Name()); err != nil {
return "", "", err
return "", err
}
}
if _, err := io.Copy(f2, f); err != nil {
f2.Close()
return "", "", err
return "", err
}
return selfExe, f2.Name(), f2.Close()
return f2.Name(), f2.Close()
}
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
@@ -932,37 +834,27 @@ func (up *Updater) updateFreeBSD() (err error) {
out, err := exec.Command("pkg", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh pkg repository indexes: %w, output:\n%s", err, out)
return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
}
out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output:\n%s", err, out)
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
}
ver := string(bytes.TrimSpace(out))
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("pkg", "upgrade", "-y", "tailscale")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd := exec.Command("pkg", "upgrade", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using pkg: %w", err)
}
// pkg does not automatically restart services after upgrade.
out, err = exec.Command("service", "tailscaled", "restart").CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restart tailscaled after update: %w, output:\n%s", err, out)
}
return nil
}
func (up *Updater) updateLinuxBinary() error {
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
return err
}
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
@@ -970,6 +862,10 @@ func (up *Updater) updateLinuxBinary() error {
if !up.confirm(ver) {
return nil
}
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
return err
}
dlPath, err := up.downloadLinuxTarball(ver)
if err != nil {
@@ -998,7 +894,7 @@ func (up *Updater) updateLinuxBinary() error {
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
dlDir, err := os.UserCacheDir()
if err != nil {
dlDir = os.TempDir()
return "", err
}
dlDir = filepath.Join(dlDir, "tailscale-update")
if err := os.MkdirAll(dlDir, 0700); err != nil {
@@ -1074,135 +970,6 @@ func (up *Updater) unpackLinuxTarball(path string) error {
return nil
}
func (up *Updater) updateQNAP() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on QNAP is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "qpkg_cli --add Tailscale"`, err)
}
}()
out, err := exec.Command("qpkg_cli", "--upgradable", "Tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed to check if Tailscale is upgradable using qpkg_cli: %w, output: %q", err, out)
}
// Output should look like this:
//
// $ qpkg_cli -G Tailscale
// [Tailscale]
// upgradeStatus = 1
statusRe := regexp.MustCompile(`upgradeStatus = (\d)`)
m := statusRe.FindStringSubmatch(string(out))
if len(m) < 2 {
return fmt.Errorf("failed to check if Tailscale is upgradable using qpkg_cli, output: %q", out)
}
status, err := strconv.Atoi(m[1])
if err != nil {
return fmt.Errorf("cannot parse upgradeStatus from qpkg_cli output %q: %w", out, err)
}
// Possible status values:
// 0:can upgrade
// 1:can not upgrade
// 2:error
// 3:can not get rss information
// 4:qpkg not found
// 5:qpkg not installed
//
// We want status 0.
switch status {
case 0: // proceed with upgrade
case 1:
up.Logf("no update available")
return nil
case 2, 3, 4:
return fmt.Errorf("failed to check update status with qpkg_cli (upgradeStatus = %d)", status)
case 5:
return errors.New("Tailscale was not found in the QNAP App Center")
default:
return fmt.Errorf("failed to check update status with qpkg_cli (upgradeStatus = %d)", status)
}
// There doesn't seem to be a way to fetch what the available upgrade
// version is. Use the generic "latest" version in confirmation prompt.
if up.Confirm != nil && !up.Confirm("latest") {
return nil
}
up.Logf("c2n: running qpkg_cli --add Tailscale")
cmd := exec.Command("qpkg_cli", "--add", "Tailscale")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using qpkg_cli: %w", err)
}
return nil
}
func (up *Updater) updateUnraid() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on Unraid is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "plugin check tailscale.plg && plugin update tailscale.plg"`, err)
}
}()
// We need to run `plugin check` for the latest tailscale.plg to get
// downloaded. Unfortunately, the output of this command does not contain
// the latest tailscale version available. So we'll parse the downloaded
// tailscale.plg file manually below.
out, err := exec.Command("plugin", "check", "tailscale.plg").CombinedOutput()
if err != nil {
return fmt.Errorf("failed to check if Tailscale plugin is upgradable: %w, output: %q", err, out)
}
// Note: 'plugin check' downloads plugins to /tmp/plugins.
// The installed .plg files are in /boot/config/plugins/, but the pending
// ones are in /tmp/plugins. We should parse the pending file downloaded by
// 'plugin check'.
latest, err := parseUnraidPluginVersion("/tmp/plugins/tailscale.plg")
if err != nil {
return fmt.Errorf("failed to find latest Tailscale version in /boot/config/plugins/tailscale.plg: %w", err)
}
if !up.confirm(latest) {
return nil
}
up.Logf("c2n: running 'plugin update tailscale.plg'")
cmd := exec.Command("plugin", "update", "tailscale.plg")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale plugin update: %w", err)
}
return nil
}
func parseUnraidPluginVersion(plgPath string) (string, error) {
plg, err := os.ReadFile(plgPath)
if err != nil {
return "", err
}
re := regexp.MustCompile(`<FILE Name="/boot/config/plugins/tailscale/tailscale_(\d+\.\d+\.\d+)_[a-z0-9]+.tgz">`)
match := re.FindStringSubmatch(string(plg))
if len(match) < 2 {
return "", errors.New("version not found in plg file")
}
return match[1], nil
}
func writeFile(r io.Reader, path string, perm os.FileMode) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove existing file at %q: %w", path, err)

View File

@@ -11,8 +11,6 @@ import (
"maps"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
)
@@ -86,6 +84,84 @@ func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
}
}
func TestParseSoftwareupdateList(t *testing.T) {
tests := []struct {
name string
input []byte
want string
}{
{
name: "update-at-end-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
* Label: Tailscale-1.23.4
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
`),
want: "Tailscale-1.23.4",
},
{
name: "update-in-middle-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Tailscale-1.23.5000
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "Tailscale-1.23.5000",
},
{
name: "update-not-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "",
},
{
name: "decoy-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Malware-1.0
Title: * Label: Tailscale-0.99.0, Version: 1.0, Size: 968K, Recommended: NOT REALLY TBH,
`),
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := parseSoftwareupdateList(test.input)
if test.want != got {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestUpdateYUMRepoTrack(t *testing.T) {
tests := []struct {
desc string
@@ -685,141 +761,3 @@ func TestWriteFileSymlink(t *testing.T) {
}
}
}
func TestCleanupOldDownloads(t *testing.T) {
tests := []struct {
desc string
before []string
symlinks map[string]string
glob string
after []string
}{
{
desc: "MSIs",
before: []string{
"MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.1.0.msi",
"MSICache/readme.txt",
},
glob: "MSICache/*.msi",
after: []string{
"MSICache/readme.txt",
},
},
{
desc: "SPKs",
before: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
"tmp/tailscale-update-2/tailscale-1.1.0.spk",
"tmp/readme.txt",
"tmp/tailscale-update-3",
"tmp/tailscale-update-4/tailscale-1.3.0",
},
glob: "tmp/tailscale-update*/*.spk",
after: []string{
"tmp/readme.txt",
"tmp/tailscale-update-3",
"tmp/tailscale-update-4/tailscale-1.3.0",
},
},
{
desc: "empty-target",
before: []string{},
glob: "tmp/tailscale-update*/*.spk",
after: []string{},
},
{
desc: "keep-dirs",
before: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
},
glob: "tmp/tailscale-update*",
after: []string{
"tmp/tailscale-update-1/tailscale-1.0.0.spk",
},
},
{
desc: "no-follow-symlinks",
before: []string{
"MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.1.0.msi",
"MSICache/readme.txt",
},
symlinks: map[string]string{
"MSICache/tailscale-1.3.0.msi": "MSICache/tailscale-1.0.0.msi",
"MSICache/tailscale-1.4.0.msi": "MSICache/readme.txt",
},
glob: "MSICache/*.msi",
after: []string{
"MSICache/tailscale-1.3.0.msi",
"MSICache/tailscale-1.4.0.msi",
"MSICache/readme.txt",
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
dir := t.TempDir()
for _, p := range tt.before {
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(p)), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, p), []byte(tt.desc), 0600); err != nil {
t.Fatal(err)
}
}
for from, to := range tt.symlinks {
if err := os.Symlink(filepath.Join(dir, to), filepath.Join(dir, from)); err != nil {
t.Fatal(err)
}
}
up := &Updater{Arguments: Arguments{Logf: t.Logf}}
up.cleanupOldDownloads(filepath.Join(dir, tt.glob))
var after []string
if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
after = append(after, strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(dir)+"/"))
}
return nil
}); err != nil {
t.Fatal(err)
}
sort.Strings(after)
sort.Strings(tt.after)
if !slices.Equal(after, tt.after) {
t.Errorf("got files after cleanup: %q, want: %q", after, tt.after)
}
})
}
}
func TestParseUnraidPluginVersion(t *testing.T) {
tests := []struct {
plgPath string
wantVer string
wantErr string
}{
{plgPath: "testdata/tailscale-1.52.0.plg", wantVer: "1.52.0"},
{plgPath: "testdata/tailscale-1.54.0.plg", wantVer: "1.54.0"},
{plgPath: "testdata/tailscale-nover.plg", wantErr: "version not found in plg file"},
{plgPath: "testdata/tailscale-nover-path-mentioned.plg", wantErr: "version not found in plg file"},
}
for _, tt := range tests {
t.Run(tt.plgPath, func(t *testing.T) {
got, err := parseUnraidPluginVersion(tt.plgPath)
if got != tt.wantVer {
t.Errorf("got version: %q, want %q", got, tt.wantVer)
}
var gotErr string
if err != nil {
gotErr = err.Error()
}
if gotErr != tt.wantErr {
t.Errorf("got error: %q, want %q", gotErr, tt.wantErr)
}
})
}
}

View File

@@ -7,14 +7,6 @@
package clientupdate
import (
"errors"
"fmt"
"os/exec"
"os/user"
"path/filepath"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"tailscale.com/util/winutil/authenticode"
)
@@ -22,7 +14,6 @@ import (
func init() {
markTempFileFunc = markTempFileWindows
verifyAuthenticode = verifyTailscale
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
}
func markTempFileWindows(name string) error {
@@ -35,50 +26,3 @@ const certSubjectTailscale = "Tailscale Inc."
func verifyTailscale(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}
func launchTailscaleAsGUIUser(exePath string) error {
exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe")
var token windows.Token
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
sessionID, err := wtsGetActiveSessionID()
if err != nil {
return fmt.Errorf("wtsGetActiveSessionID(): %w", err)
}
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return fmt.Errorf("WTSQueryUserToken (0x%x): %w", sessionID, err)
}
defer token.Close()
}
cmd := exec.Command(exePath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Token: syscall.Token(token),
HideWindow: true,
}
return cmd.Start()
}
func wtsGetActiveSessionID() (uint32, error) {
var (
sessionInfo *windows.WTS_SESSION_INFO
count uint32 = 0
)
const WTS_CURRENT_SERVER_HANDLE = 0
if err := windows.WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1, &sessionInfo, &count); err != nil {
return 0, fmt.Errorf("WTSEnumerateSessions: %w", err)
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessionInfo)))
current := unsafe.Pointer(sessionInfo)
for i := uint32(0); i < count; i++ {
session := (*windows.WTS_SESSION_INFO)(current)
if session.State == windows.WTSActive {
return session.SessionID, nil
}
current = unsafe.Add(current, unsafe.Sizeof(windows.WTS_SESSION_INFO{}))
}
return 0, errors.New("no active desktop sessions found")
}

View File

@@ -1,3 +1,3 @@
-----BEGIN ROOT PUBLIC KEY-----
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
Muw5GkO5mASsJ7k6kS+svfuanr6XcW9I7fPGtyqOTeI=
-----END ROOT PUBLIC KEY-----

View File

@@ -1,115 +0,0 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.01"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.11.01###
- Update Tailscale to 1.52.0 (new checksum from upstream package server)
###2023.10.31###
- Update Tailscale to 1.52.0
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
###2023.09.14a###
- Update Tailscale to 1.48.2
###2023.08.22###
- Update Tailscale to 1.48.1
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/tailscale_1.52.0_amd64.tgz">
<URL>https://pkgs.tailscale.com/stable/tailscale_1.52.0_amd64.tgz</URL>
<MD5>b4d15d9908737e08e3f95ed5104603ce</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
tar xzf /boot/config/plugins/tailscale/tailscale_1.52.0_amd64.tgz --strip-components 1 -C /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.01" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.52.0_amd64')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.01"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -1,112 +0,0 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.18"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.11.18###
- Update Tailscale to 1.54.0
###2023.11.01###
- Update Tailscale to 1.52.0 (new checksum from upstream package server)
###2023.10.31###
- Update Tailscale to 1.52.0
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/tailscale_1.54.0_amd64.tgz">
<URL>https://pkgs.tailscale.com/stable/tailscale_1.54.0_amd64.tgz</URL>
<MD5>20187743e0c1c1a0d9fea47a10b6a9ba</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
tar xzf /boot/config/plugins/tailscale/tailscale_1.54.0_amd64.tgz --strip-components 1 -C /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.18" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.54.0_amd64')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.18"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -1,110 +0,0 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.01"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.11.01###
- Update Tailscale to 1.52.0 (new checksum from upstream package server)
###2023.10.31###
- Update Tailscale to 1.52.0
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
###2023.09.14a###
- Update Tailscale to 1.48.2
###2023.08.22###
- Update Tailscale to 1.48.1
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
tar xzf /boot/config/plugins/tailscale/tailscale_1.52.0_amd64.tgz --strip-components 1 -C /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.01" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
rm -f $(ls /boot/config/plugins/tailscale/*.tgz 2>/dev/null | grep -v 'tailscale_1.52.0_amd64')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.01"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -1,102 +0,0 @@
<?xml version='1.0' standalone='yes'?>
<!DOCTYPE PLUGIN>
<PLUGIN
name="tailscale"
author="Derek Kaser"
version="2023.11.01"
pluginURL="https://raw.githubusercontent.com/dkaser/unraid-tailscale/main/plugin/tailscale.plg"
launch="Settings/Tailscale"
support="https://forums.unraid.net/topic/136889-plugin-tailscale/"
>
<CHANGES>
<![CDATA[
###2023.10.29###
- Update Tailscale to 1.50.1
- Fix nginx hang when Tailscale restarts
###2023.09.26###
- Update Tailscale to 1.50.0
- New Tailscale web interface
###2023.09.14a###
- Update Tailscale to 1.48.2
###2023.08.22###
- Update Tailscale to 1.48.1
For older releases, see https://github.com/dkaser/unraid-tailscale/releases
]]>
</CHANGES>
<FILE Name="/boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-tailscale-utils/releases/download/1.4.1/unraid-tailscale-utils-1.4.1-noarch-1.txz</URL>
<MD5>7095ab4b88b34d8f5da6483865883267</MD5>
</FILE>
<FILE Name="/boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz">
<URL>https://github.com/dkaser/unraid-plugin-diagnostics/releases/download/1.2.2/unraid-plugin-diagnostics-1.2.2-noarch-1.txz</URL>
<MD5>9d358575499305889962d83ebd90c20c</MD5>
</FILE>
<!--
The 'install' script.
-->
<FILE Run="/bin/bash">
<INLINE>
<![CDATA[
if [ -d "/usr/local/emhttp/plugins/tailscale" ]; then
rm -rf /usr/local/emhttp/plugins/tailscale
fi
upgradepkg --install-new /boot/config/plugins/tailscale/unraid-plugin-diagnostics-1.2.2-noarch-1.txz
upgradepkg --install-new --reinstall /boot/config/plugins/tailscale/unraid-tailscale-utils-1.4.1-noarch-1.txz
mkdir -p /usr/local/emhttp/plugins/tailscale/bin
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscale /usr/local/sbin/tailscale
ln -s /usr/local/emhttp/plugins/tailscale/bin/tailscaled /usr/local/sbin/tailscaled
mkdir -p /var/local/emhttp/plugins/tailscale
echo "VERSION=2023.11.01" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
# start tailscaled
/usr/local/emhttp/plugins/tailscale/restart.sh
# cleanup old versions
rm -f /boot/config/plugins/tailscale/tailscale-utils-*.txz
rm -f $(ls /boot/config/plugins/tailscale/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '1.4.1')
rm -f $(ls /boot/config/plugins/tailscale/unraid-plugin-diagnostics-*.txz 2>/dev/null | grep -v '1.2.2')
echo ""
echo "----------------------------------------------------"
echo " tailscale has been installed."
echo " Version: 2023.11.01"
echo "----------------------------------------------------"
echo ""
]]>
</INLINE>
</FILE>
<!--
The 'remove' script.
-->
<FILE Run="/bin/bash" Method="remove">
<INLINE>
<![CDATA[
# Stop service
/etc/rc.d/rc.tailscale stop 2>/dev/null
rm /usr/local/sbin/tailscale
rm /usr/local/sbin/tailscaled
removepkg unraid-tailscale-utils-1.4.1
rm -rf /usr/local/emhttp/plugins/tailscale
rm -rf /boot/config/plugins/tailscale
]]>
</INLINE>
</FILE>
</PLUGIN>

View File

@@ -13,15 +13,14 @@
//
// - TS_AUTHKEY: the authkey to use for login.
// - TS_HOSTNAME: the hostname to request for the node.
// - TS_ROUTES: subnet routes to advertise. To accept routes, use TS_EXTRA_ARGS to pass in --accept-routes.
// - TS_ROUTES: subnet routes to advertise.
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
// 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
// destination defined by a MagicDNS name.
// destination.
// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'.
// - TS_EXTRA_ARGS: extra arguments to 'tailscale up'.
// - TS_EXTRA_ARGS: extra arguments to 'tailscale login', these are not
// reset on restart.
// - TS_USERSPACE: run with userspace networking (the default)
// instead of kernel networking.
// - TS_STATE_DIR: the directory in which to store tailscaled
@@ -37,9 +36,15 @@
// - TS_SOCKET: the path where the tailscaled LocalAPI socket should
// be created.
// - TS_AUTH_ONCE: if true, only attempt to log in if not already
// logged in. If false (the default, for backwards
// compatibility), forcibly log in every time the
// container starts.
// logged in. If false, forcibly log in every time the container starts.
// The default until 1.50.0 was false, but that was misleading: until
// 1.50, containerboot used `tailscale up` which would ignore an authkey
// argument if there was already a node key. Effectively, this behaved
// as though TS_AUTH_ONCE were always true.
// In 1.50.0 the change was made to use `tailscale login` instead of `up`,
// and login will reauthenticate every time it is given an authkey.
// In 1.50.1 we set the TS_AUTH_ONCE to true, to match the previously
// observed behavior.
// - TS_SERVE_CONFIG: if specified, is the file path where the ipn.ServeConfig is located.
// It will be applied once tailscaled is up and running. If the file contains
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
@@ -71,7 +76,6 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -80,44 +84,33 @@ import (
"golang.org/x/sys/unix"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/ptr"
"tailscale.com/util/deephash"
"tailscale.com/util/linuxfw"
)
func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
return linuxfw.NewFakeIPTablesRunner(), nil
}
return linuxfw.New(logf)
}
func main() {
log.SetPrefix("boot: ")
tailscale.I_Acknowledge_This_API_Is_Unstable = true
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnv("TS_ROUTES", ""),
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: defaultBool("TS_ACCEPT_DNS", false),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnv("TS_ROUTES", ""),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", true),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
}
if cfg.ProxyTo != "" && cfg.UserspaceMode {
@@ -127,19 +120,13 @@ func main() {
if cfg.TailnetTargetIP != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.TailnetTargetIP != "" {
log.Fatal("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if !cfg.UserspaceMode {
if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("Unable to create tuntap device file: %v", err)
}
if cfg.ProxyTo != "" || cfg.Routes != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
if cfg.ProxyTo != "" || cfg.Routes != "" || cfg.TailnetTargetIP != "" {
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, 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 {
@@ -192,16 +179,10 @@ func main() {
}
}
client, daemonProcess, err := startTailscaled(bootCtx, cfg)
client, daemonPid, err := startTailscaled(bootCtx, cfg)
if err != nil {
log.Fatalf("failed to bring up tailscale: %v", err)
}
killTailscaled := func() {
if err := daemonProcess.Signal(unix.SIGTERM); err != nil {
log.Fatalf("error shutting tailscaled down: %v", err)
}
}
defer killTailscaled()
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
if err != nil {
@@ -222,7 +203,7 @@ func main() {
}
didLogin = true
w.Close()
if err := tailscaleUp(bootCtx, cfg); err != nil {
if err := tailscaleLogin(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %v", err)
}
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState)
@@ -269,15 +250,13 @@ authLoop:
w.Close()
ctx, cancel := contextWithExitSignalWatch()
ctx, cancel := context.WithCancel(context.Background()) // no deadline now that we're in steady state
defer cancel()
if cfg.AuthOnce {
// Now that we are authenticated, we can set/reset any of the
// settings that we need to.
if err := tailscaleSet(ctx, cfg); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
}
// Now that we are authenticated, we can set/reset any of the
// settings that we need to.
if err := tailscaleSet(ctx, cfg); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
}
if cfg.ServeConfigPath != "" {
@@ -304,172 +283,96 @@ authLoop:
}
var (
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != ""
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != ""
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
certDomain = new(atomic.Pointer[string])
certDomainChanged = make(chan bool, 1)
)
if cfg.ServeConfigPath != "" {
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
}
var nfr linuxfw.NetfilterRunner
if wantProxy {
nfr, err = newNetfilterRunner(log.Printf)
if err != nil {
log.Fatalf("error creating new netfilter runner: %v", err)
}
}
notifyChan := make(chan ipn.Notify)
errChan := make(chan error)
go func() {
for {
n, err := w.Next()
if err != nil {
errChan <- err
break
} else {
notifyChan <- n
}
}
}()
var wg sync.WaitGroup
runLoop:
for {
select {
case <-ctx.Done():
// Although killTailscaled() is deferred earlier, if we
// have started the reaper defined below, we need to
// kill tailscaled and let reaper clean up child
// processes.
killTailscaled()
break runLoop
case err := <-errChan:
n, err := w.Next()
if err != nil {
log.Fatalf("failed to read from tailscaled: %v", err)
case n := <-notifyChan:
if n.State != nil && *n.State != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.State != nil && *n.State != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.NetMap != nil {
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
if n.NetMap != nil {
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
if cfg.TailnetTargetFQDN != "" {
var (
egressAddrs []netip.Prefix
newCurentEgressIPs deephash.Sum
egressIPsHaveChanged bool
node tailcfg.NodeView
nodeFound bool
)
for _, n := range n.NetMap.Peers {
if strings.EqualFold(n.Name(), cfg.TailnetTargetFQDN) {
node = n
nodeFound = true
break
}
}
if !nodeFound {
log.Printf("Tailscale node %q not found; it either does not exist, or not reachable because of ACLs", cfg.TailnetTargetFQDN)
break
}
egressAddrs = node.Addresses().AsSlice()
newCurentEgressIPs = deephash.Hash(&egressAddrs)
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
if egressIPsHaveChanged && len(egressAddrs) > 0 {
for _, egressAddr := range egressAddrs {
ea := egressAddr.Addr()
// TODO (irbekrm): make it work for IPv6 too.
if ea.Is6() {
log.Println("Not installing egress forwarding rules for IPv6 as this is currently not supported")
continue
}
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
}
}
}
currentEgressIPs = newCurentEgressIPs
}
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
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 {
select {
case certDomainChanged <- true:
default:
}
}
}
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)
}
}
currentIPs = newCurrentIPs
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 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 {
select {
case certDomainChanged <- true:
default:
}
}
}
if !startupTasksDone {
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs); err != nil {
log.Fatalf("installing egress proxy rules: %v", err)
}
}
currentIPs = newCurrentIPs
// 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
pid, err := unix.Wait4(-1, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for exited processes: %v", err)
}
if pid == daemonProcess.Pid {
log.Printf("Tailscaled exited")
os.Exit(0)
}
}
}
wg.Add(1)
go reaper()
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 {
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
// Reap all processes, since we are PID1 and need to collect zombies. We can
// only start doing this once we've stopped shelling out to things
// `tailscale up`, otherwise this goroutine can reap the CLI subprocesses
// and wedge bringup.
go func() {
for {
var status unix.WaitStatus
pid, err := unix.Wait4(-1, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for exited processes: %v", err)
}
if pid == daemonPid {
log.Printf("Tailscaled exited")
os.Exit(0)
}
}
}()
}
}
}
wg.Wait()
}
// watchServeConfigChanges watches path for changes, and when it sees one, reads
@@ -482,20 +385,19 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
panic("cd must not be nil")
}
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
w, err := fsnotify.NewWatcher()
if err != nil {
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
}
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("failed to add fsnotify watch: %v", err)
}
var certDomain string
var prevServeConfig *ipn.ServeConfig
for {
@@ -505,7 +407,7 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
case <-cdChanged:
certDomain = *certDomainAtomic.Load()
case <-tickChan:
case <-eventChan:
case <-w.Events:
// We can't do any reasonable filtering on the event because of how
// k8s handles these mounts. So just re-read the file and apply it
// if it's changed.
@@ -546,8 +448,10 @@ func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
return &sc, nil
}
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, int, error) {
args := tailscaledArgs(cfg)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, unix.SIGTERM, unix.SIGINT)
// tailscaled runs without context, since it needs to persist
// beyond the startup timeout in ctx.
cmd := exec.Command("tailscaled", args...)
@@ -558,8 +462,13 @@ func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient
}
log.Printf("Starting tailscaled")
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
return nil, 0, fmt.Errorf("starting tailscaled failed: %v", err)
}
go func() {
<-sigCh
log.Printf("Received SIGTERM from container runtime, shutting down tailscaled")
cmd.Process.Signal(unix.SIGTERM)
}()
// Wait for the socket file to appear, otherwise API ops will racily fail.
log.Printf("Waiting for tailscaled socket")
@@ -582,7 +491,7 @@ func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient
UseSocketOnly: true,
}
return tsClient, cmd.Process, nil
return tsClient, cmd.Process.Pid, nil
}
// tailscaledArgs uses cfg to construct the argv for tailscaled.
@@ -619,40 +528,29 @@ func tailscaledArgs(cfg *settings) []string {
return args
}
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
// tailscaleLogin uses cfg to run 'tailscale login' everytime containerboot
// starts, or if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleLogin(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "login"}
if cfg.AuthKey != "" {
args = append(args, "--authkey="+cfg.AuthKey)
}
if cfg.Routes != "" {
args = append(args, "--advertise-routes="+cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
if cfg.ExtraArgs != "" {
args = append(args, strings.Fields(cfg.ExtraArgs)...)
}
log.Printf("Running 'tailscale up'")
log.Printf("Running 'tailscale login'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale up failed: %v", err)
return fmt.Errorf("tailscale login failed: %v", err)
}
return nil
}
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
// options that are passed in via environment variables. This is run after the
// node is in Running state and only if TS_AUTH_ONCE is set.
// node is in Running state.
func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS {
@@ -696,7 +594,7 @@ func ensureTunFile(root string) error {
}
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTargetFQDN, routes string) error {
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string) error {
var (
v4Forwarding, v6Forwarding bool
)
@@ -722,11 +620,6 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTarget
v6Forwarding = true
}
}
// Currently we only proxy traffic to the IPv4 address of the tailnet
// target.
if tailnetTargetFQDN != "" {
v4Forwarding = true
}
if routes != "" {
for _, route := range strings.Split(routes, ",") {
cidr, err := netip.ParsePrefix(route)
@@ -769,12 +662,16 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTarget
return nil
}
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
argv0 := "iptables"
if dst.Is6() {
argv0 = "ip6tables"
}
var local string
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
@@ -782,30 +679,52 @@ func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []net
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
local = pfx.Addr().String()
break
}
if !local.IsValid() {
if local == "" {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
// Technically, if the control server ever changes the IPs assigned to this
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
// for now we'll live with it.
// Set up a rule that ensures that all packets
// except for those received on tailscale0 interface is forwarded to
// destination address
cmdDNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "!", "-i", "tailscale0", "-j", "DNAT", "--to-destination", dstStr)
cmdDNAT.Stdout = os.Stdout
cmdDNAT.Stderr = os.Stderr
if err := cmdDNAT.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
}
if err := nfr.AddSNATRuleForDst(local, dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
// Set up a rule that ensures that all packets sent to the destination
// address will have the proxy's IP set as source IP
cmdSNAT := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "POSTROUTING", "1", "--destination", dstStr, "-j", "SNAT", "--to-source", local)
cmdSNAT.Stdout = os.Stdout
cmdSNAT.Stderr = os.Stderr
if err := cmdSNAT.Run(); err != nil {
return fmt.Errorf("setting up SNAT via iptables failed: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
cmdClamp := exec.CommandContext(ctx, argv0, "-t", "mangle", "-A", "FORWARD", "-o", "tailscale0", "-p", "tcp", "-m", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
cmdClamp.Stdout = os.Stdout
cmdClamp.Stderr = os.Stderr
if err := cmdClamp.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
}
return nil
}
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
argv0 := "iptables"
if dst.Is6() {
argv0 = "ip6tables"
}
var local string
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
@@ -813,17 +732,26 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
local = pfx.Addr().String()
break
}
if !local.IsValid() {
if local == "" {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.AddDNATRule(local, dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
// Technically, if the control server ever changes the IPs assigned to this
// node, we'll slowly accumulate iptables rules. This shouldn't happen, so
// for now we'll live with it.
cmd := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "-d", local, "-j", "DNAT", "--to-destination", dstStr)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
cmdClamp := exec.CommandContext(ctx, argv0, "-t", "mangle", "-A", "FORWARD", "-o", "tailscale0", "-p", "tcp", "-m", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
cmdClamp.Stdout = os.Stdout
cmdClamp.Stderr = os.Stderr
if err := cmdClamp.Run(); err != nil {
return fmt.Errorf("executing iptables failed: %w", err)
}
return nil
}
@@ -838,13 +766,9 @@ type settings struct {
// is done. This is typically a locally reachable IP.
ProxyTo string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. This is typically a
// Tailscale IP.
TailnetTargetIP string
// 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
// non-Tailscale traffic should be proxied. If empty, no
// proxying is done. This is typically a Tailscale IP.
TailnetTargetIP string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
@@ -889,25 +813,3 @@ func defaultBool(name string, defVal bool) bool {
}
return ret
}
// contextWithExitSignalWatch watches for SIGTERM/SIGINT signals. It returns a
// context that gets cancelled when a signal is received and a cancel function
// that can be called to free the resources when the watch should be stopped.
func contextWithExitSignalWatch() (context.Context, func()) {
closeChan := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case <-signalChan:
cancel()
case <-closeChan:
return
}
}()
f := func() {
closeChan <- "goodbye"
}
return ctx, f
}

View File

@@ -129,16 +129,22 @@ func TestContainerBoot(t *testing.T) {
{
// Out of the box default: runs in userspace mode, ephemeral storage, interactive login.
Name: "no_args",
Env: nil,
Env: map[string]string{
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -146,17 +152,21 @@ func TestContainerBoot(t *testing.T) {
// Userspace mode, ephemeral storage, authkey provided on every run.
Name: "authkey",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -164,17 +174,21 @@ func TestContainerBoot(t *testing.T) {
// Userspace mode, ephemeral storage, authkey provided on every run.
Name: "authkey-old-flag",
Env: map[string]string{
"TS_AUTH_KEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -183,30 +197,35 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_STATE_DIR": filepath.Join(d, "tmp"),
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
{
Name: "routes",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -215,6 +234,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "0",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
},
},
@@ -224,12 +246,13 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -238,6 +261,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "0",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=1.2.3.0/24,10.20.30.0/24",
},
},
},
},
@@ -247,12 +273,13 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "::/64,1::/64",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1::/64",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -261,6 +288,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "0",
"proc/sys/net/ipv6/conf/all/forwarding": "1",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1::/64",
},
},
},
},
@@ -270,12 +300,13 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "::/64,1.2.3.0/24",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1.2.3.0/24",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -284,6 +315,9 @@ func TestContainerBoot(t *testing.T) {
"proc/sys/net/ipv4/ip_forward": "1",
"proc/sys/net/ipv6/conf/all/forwarding": "1",
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --advertise-routes=::/64,1.2.3.0/24",
},
},
},
},
@@ -293,16 +327,22 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_DEST_IP": "1.2.3.4",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/iptables -t nat -I PREROUTING 1 -d 100.64.0.1 -j DNAT --to-destination 1.2.3.4",
"/usr/bin/iptables -t mangle -A FORWARD -o tailscale0 -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu",
},
},
},
},
@@ -312,16 +352,23 @@ func TestContainerBoot(t *testing.T) {
"TS_AUTHKEY": "tskey-key",
"TS_TAILNET_TARGET_IP": "100.99.99.99",
"TS_USERSPACE": "false",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
"/usr/bin/iptables -t nat -I PREROUTING 1 ! -i tailscale0 -j DNAT --to-destination 100.99.99.99",
"/usr/bin/iptables -t nat -I POSTROUTING 1 --destination 100.99.99.99 -j SNAT --to-source 100.64.0.1",
"/usr/bin/iptables -t mangle -A FORWARD -o tailscale0 -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu",
},
},
},
},
@@ -342,7 +389,7 @@ func TestContainerBoot(t *testing.T) {
State: ptr.To(ipn.NeedsLogin),
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
},
{
@@ -358,6 +405,7 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -366,7 +414,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -374,6 +422,9 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
@@ -392,18 +443,22 @@ func TestContainerBoot(t *testing.T) {
"TS_KUBE_SECRET": "",
"TS_STATE_DIR": filepath.Join(d, "tmp"),
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{},
},
{
Notify: runningNotify,
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{},
},
},
@@ -414,6 +469,7 @@ func TestContainerBoot(t *testing.T) {
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{},
KubeDenyPatch: true,
@@ -421,12 +477,15 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{},
},
{
Notify: runningNotify,
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{},
},
},
@@ -456,7 +515,7 @@ func TestContainerBoot(t *testing.T) {
State: ptr.To(ipn.NeedsLogin),
},
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -480,6 +539,7 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTH_ONCE": "false",
},
KubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -488,7 +548,7 @@ func TestContainerBoot(t *testing.T) {
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --authkey=tskey-key",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
@@ -496,6 +556,9 @@ func TestContainerBoot(t *testing.T) {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
@@ -528,16 +591,20 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"TS_SOCKS5_SERVER": "localhost:1080",
"TS_OUTBOUND_HTTP_PROXY_LISTEN": "localhost:8080",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --socks5-server=localhost:1080 --outbound-http-proxy-listen=localhost:8080",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
@@ -545,16 +612,20 @@ func TestContainerBoot(t *testing.T) {
Name: "dns",
Env: map[string]string{
"TS_ACCEPT_DNS": "true",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=true",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=true",
},
},
},
},
@@ -563,47 +634,41 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"TS_EXTRA_ARGS": "--widget=rotated",
"TS_TAILSCALED_EXTRA_ARGS": "--experiments=widgets",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --experiments=widgets",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --widget=rotated",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login --widget=rotated",
},
}, {
Notify: runningNotify,
},
},
},
{
Name: "extra_args_accept_routes",
Env: map[string]string{
"TS_EXTRA_ARGS": "--accept-routes",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --accept-routes",
},
}, {
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
},
},
},
{
Name: "hostname",
Env: map[string]string{
"TS_HOSTNAME": "my-server",
"TS_HOSTNAME": "my-server",
"TS_AUTH_ONCE": "false",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --hostname=my-server",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock login",
},
}, {
},
{
Notify: runningNotify,
WantCmds: []string{
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false --hostname=my-server",
},
},
},
},
@@ -629,7 +694,6 @@ func TestContainerBoot(t *testing.T) {
fmt.Sprintf("TS_TEST_SOCKET=%s", lapi.Path),
fmt.Sprintf("TS_SOCKET=%s", runningSockPath),
fmt.Sprintf("TS_TEST_ONLY_ROOT=%s", d),
fmt.Sprint("TS_TEST_FAKE_NETFILTER=true"),
}
for k, v := range test.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))

View File

@@ -2,6 +2,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
W 💣 github.com/Microsoft/go-winio/internal/fs from github.com/Microsoft/go-winio
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
W github.com/Microsoft/go-winio/internal/stringbuffer from github.com/Microsoft/go-winio/internal/fs
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -12,13 +17,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header
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/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/interfaces+
@@ -39,11 +44,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
@@ -79,6 +79,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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+
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs
💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+
gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log
gvisor.dev/gvisor/pkg/log from gvisor.dev/gvisor/pkg/context+
gvisor.dev/gvisor/pkg/refs from gvisor.dev/gvisor/pkg/buffer
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+
gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+
gvisor.dev/gvisor/pkg/tcpip/header from tailscale.com/net/packet
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
nhooyr.io/websocket from tailscale.com/cmd/derper+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
@@ -112,7 +128,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
tailscale.com/paths from tailscale.com/client/tailscale
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
@@ -148,11 +164,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L tailscale.com/util/linuxfw from tailscale.com/net/netns
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/health+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/vizerror from tailscale.com/tsweb+
tailscale.com/util/vizerror from tailscale.com/tsweb
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
@@ -219,7 +234,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/tls from golang.org/x/crypto/acme+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+

View File

@@ -12,7 +12,6 @@ import (
"testing"
"tailscale.com/net/stun"
"tailscale.com/tstest/deptest"
)
func TestProdAutocertHostPolicy(t *testing.T) {
@@ -129,14 +128,3 @@ func TestNoContent(t *testing.T) {
})
}
}
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
"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",
},
}.Check(t)
}

View File

@@ -41,7 +41,6 @@ func startMeshWithHost(s *derp.Server, host string) error {
return err
}
c.MeshKey = s.MeshKey()
c.WatchConnectionChanges = true
// For meshed peers within a region, connect via VPC addresses.
c.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) {

View File

@@ -1,23 +0,0 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -1,29 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v2
name: tailscale-operator
description: A Helm chart for Tailscale Kubernetes operator
home: https://github.com/tailscale/tailscale
keywords:
- "tailscale"
- "vpn"
- "ingress"
- "egress"
- "wireguard"
sources:
- https://github.com/tailscale/tailscale
type: application
maintainers:
- name: tailscale-maintainers
url: https://tailscale.com/
# version will be set to Tailscale repo tag (without 'v') at release time.
version: 0.1.0
# appVersion will be set to Tailscale repo tag at release time.
appVersion: "unstable"

View File

@@ -1,26 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
{{ if eq .Values.apiServerProxyConfig.mode "true" }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-auth-proxy
rules:
- apiGroups: [""]
resources: ["users", "groups"]
verbs: ["impersonate"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-auth-proxy
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: tailscale-auth-proxy
apiGroup: rbac.authorization.k8s.io
{{ end }}

View File

@@ -1,90 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: apps/v1
kind: Deployment
metadata:
name: operator
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: operator
template:
metadata:
{{- with .Values.operatorConfig.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
app: operator
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: operator
{{- with .Values.operatorConfig.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: oauth
secret:
secretName: operator-oauth
containers:
- name: operator
{{- with .Values.operatorConfig.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.operatorConfig.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $operatorTag:= printf ":%s" ( .Values.operatorConfig.image.tag | default .Chart.AppVersion )}}
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_HOSTNAME
value: {{ .Values.operatorConfig.hostname }}
- name: OPERATOR_SECRET
value: operator
- name: OPERATOR_LOGGING
value: {{ .Values.operatorConfig.logging }}
- name: OPERATOR_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- 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: {{ .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
value: "{{ .Values.apiServerProxyConfig.mode }}"
- name: PROXY_FIREWALL_MODE
value: {{ .Values.proxyConfig.firewallMode }}
volumeMounts:
- name: oauth
mountPath: /oauth
readOnly: true
{{- with .Values.operatorConfig.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,13 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
{{ if and .Values.oauth .Values.oauth.clientId -}}
apiVersion: v1
kind: Secret
metadata:
name: operator-oauth
namespace: {{ .Release.Namespace }}
stringData:
client_id: {{ .Values.oauth.clientId }}
client_secret: {{ .Values.oauth.clientSecret }}
{{- end -}}

View File

@@ -1,60 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: ServiceAccount
metadata:
name: operator
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-operator
rules:
- apiGroups: [""]
resources: ["events", "services", "services/status"]
verbs: ["*"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "ingresses/status"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-operator
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: tailscale-operator
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: operator
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: operator
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: operator
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: operator
apiGroup: rbac.authorization.k8s.io

View File

@@ -1,32 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: v1
kind: ServiceAccount
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: proxies
namespace: {{ .Release.Namespace }}
subjects:
- kind: ServiceAccount
name: proxies
namespace: {{ .Release.Namespace }}
roleRef:
kind: Role
name: proxies
apiGroup: rbac.authorization.k8s.io

View File

@@ -1,59 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Operator oauth credentials. If set a Kubernetes Secret with the provided
# values will be created in the operator namespace. If unset a Secret named
# operator-oauth must be precreated.
oauth: {}
# clientId: ""
# clientSecret: ""
operatorConfig:
image:
repo: tailscale/k8s-operator
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
digest: ""
pullPolicy: Always
logging: "info"
hostname: "tailscale-operator"
nodeSelector:
kubernetes.io/os: linux
resources: {}
podAnnotations: {}
tolerations: []
affinity: {}
podSecurityContext: {}
securityContext: {}
# proxyConfig contains configuraton that will be applied to any ingress/egress
# proxies created by the operator.
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
proxyConfig:
image:
repo: tailscale/tailscale
# Digest will be prioritized over tag. If neither are set appVersion will be
# used.
tag: ""
digest: ""
# ACL tag that operator will tag proxies with. Operator must be made owner of
# these tags
# https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator
defaultTags: tag:k8s
firewallMode: auto
# apiServerProxyConfig allows to configure whether the operator should expose
# Kubernetes API server.
# https://tailscale.com/kb/1236/kubernetes-operator/#accessing-the-kubernetes-control-plane-using-an-api-server-proxy
apiServerProxyConfig:
mode: "false" # "true", "false", "noauth"
imagePullSecrets: []

View File

@@ -192,15 +192,8 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
}
}
addIngressBackend(ing.Spec.DefaultBackend, "/")
var tlsHost string // hostname or FQDN or empty
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
tlsHost = ing.Spec.TLS[0].Hosts[0]
}
for _, rule := range ing.Spec.Rules {
// Host is optional, but if it's present it must match the TLS host
// otherwise we ignore the rule.
if rule.Host != "" && rule.Host != tlsHost {
if rule.Host != "" {
a.recorder.Eventf(ing, corev1.EventTypeWarning, "InvalidIngressBackend", "rule with host %q ignored, unsupported", rule.Host)
continue
}
@@ -215,8 +208,8 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
tags = strings.Split(tstr, ",")
}
hostname := ing.Namespace + "-" + ing.Name + "-ingress"
if tlsHost != "" {
hostname, _, _ = strings.Cut(tlsHost, ".")
if ing.Spec.TLS != nil && len(ing.Spec.TLS) > 0 && len(ing.Spec.TLS[0].Hosts) > 0 {
hostname, _, _ = strings.Cut(ing.Spec.TLS[0].Hosts[0], ".")
}
sts := &tailscaleSTSConfig{

View File

@@ -151,10 +151,8 @@ spec:
value: tailscale/tailscale:unstable
- name: PROXY_TAGS
value: tag:k8s
- name: APISERVER_PROXY
- name: AUTH_PROXY
value: "false"
- name: PROXY_FIREWALL_MODE
value: auto
volumeMounts:
- name: oauth
mountPath: /oauth

View File

@@ -12,6 +12,7 @@ spec:
serviceAccountName: proxies
initContainers:
- name: sysctler
image: busybox
securityContext:
privileged: true
command: ["/bin/sh"]

View File

@@ -10,7 +10,6 @@ package main
import (
"context"
"os"
"regexp"
"strings"
"time"
@@ -53,7 +52,6 @@ func main() {
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
)
var opts []kzap.Opts
@@ -68,27 +66,18 @@ func main() {
zlog := kzap.NewRaw(opts...).Sugar()
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
// The operator can run either as a plain operator or it can
// additionally act as api-server proxy
// https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy.
mode := parseAPIProxyMode()
if mode == apiserverProxyModeDisabled {
hostinfo.SetApp("k8s-operator")
} else {
hostinfo.SetApp("k8s-operator-proxy")
}
s, tsClient := initTSNet(zlog)
defer s.Close()
restConfig := config.GetConfigOrDie()
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
maybeLaunchAPIServerProxy(zlog, restConfig, s)
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags)
}
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate
// with Tailscale.
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
hostinfo.SetApp("k8s-operator")
var (
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
@@ -190,7 +179,7 @@ waitOnline:
// runReconcilers starts the controller-runtime manager and registers the
// ServiceReconciler. It blocks forever.
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags string) {
var (
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
)
@@ -227,7 +216,6 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
operatorNamespace: tsNamespace,
proxyImage: image,
proxyPriorityClassName: priorityClassName,
tsFirewallMode: tsFirewallMode,
}
err = builder.
ControllerManagedBy(mgr).
@@ -240,7 +228,6 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
Client: mgr.GetClient(),
logger: zlog.Named("service-reconciler"),
isDefaultLoadBalancer: isDefaultLoadBalancer,
recorder: eventRecorder,
})
if err != nil {
startlog.Fatalf("could not create controller: %v", err)
@@ -323,10 +310,3 @@ func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
}
}
// isMagicDNSName reports whether name is a full tailnet node FQDN (with or
// without final dot).
func isMagicDNSName(name string) bool {
validMagicDNSName := regexp.MustCompile(`^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net\.?$`)
return validMagicDNSName.MatchString(name)
}

View File

@@ -70,12 +70,7 @@ func TestLoadBalancerClass(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -159,119 +154,6 @@ func TestLoadBalancerClass(t *testing.T) {
}
expectEqual(t, fc, want)
}
func TestTailnetTargetFQDNAnnotation(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
tailnetTargetFQDN := "foo.bar.ts.net."
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
},
logger: zl.Sugar(),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
},
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: map[string]string{
"foo": "bar",
},
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
Finalizers: []string{"tailscale.com/finalizer"},
UID: types.UID("1234-UID"),
Annotations: map[string]string{
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
},
},
Spec: corev1.ServiceSpec{
ExternalName: fmt.Sprintf("%s.operator-ns.svc.cluster.local", shortName),
Type: corev1.ServiceTypeExternalName,
Selector: nil,
},
}
expectEqual(t, fc, want)
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o = stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetFQDN: tailnetTargetFQDN,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
// Change the tailscale-target-fqdn annotation which should update the
// StatefulSet
tailnetTargetFQDN = "bar.baz.ts.net"
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = map[string]string{
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
}
})
// Remove the tailscale-target-fqdn annotation which should make the
// operator clean up
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.ObjectMeta.Annotations = map[string]string{}
})
expectReconciled(t, sr, "default", "test")
// // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
// // didn't create any child resources since this is all faked, so the
// // deletion goes through immediately.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
// // The deletion triggers another reconcile, to finish the cleanup.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
}
func TestTailnetTargetIPAnnotation(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
@@ -320,13 +202,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetIP: tailnetTargetIP,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -350,13 +226,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectEqual(t, fc, want)
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o = stsOpts{
name: shortName,
secretName: fullName,
tailnetTargetIP: tailnetTargetIP,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedEgressSTS(shortName, fullName, tailnetTargetIP, "default-test", ""))
// Change the tailscale-target-ip annotation which should update the
// StatefulSet
@@ -384,6 +254,10 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
// At the moment we don't revert changes to the user created Service -
// we don't have a reliable way how to tell what it was before and also
// we don't really expect it to be re-used
}
func TestAnnotations(t *testing.T) {
@@ -431,12 +305,7 @@ func TestAnnotations(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -536,12 +405,7 @@ func TestAnnotationIntoLB(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, since it would have normally happened at
@@ -586,12 +450,7 @@ func TestAnnotationIntoLB(t *testing.T) {
expectReconciled(t, sr, "default", "test")
// None of the proxy machinery should have changed...
expectEqual(t, fc, expectedHeadlessService(shortName))
o = stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// ... but the service should have a LoadBalancer status.
want = &corev1.Service{
@@ -669,12 +528,7 @@ func TestLBIntoAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -737,12 +591,7 @@ func TestLBIntoAnnotation(t *testing.T) {
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedHeadlessService(shortName))
o = stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
@@ -812,12 +661,7 @@ func TestCustomHostname(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "reindeer-flotilla",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -891,7 +735,7 @@ func TestCustomPriorityClassName(t *testing.T) {
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
proxyPriorityClassName: "custom-priority-class-name",
proxyPriorityClassName: "tailscale-critical",
},
logger: zl.Sugar(),
}
@@ -908,7 +752,7 @@ func TestCustomPriorityClassName(t *testing.T) {
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/expose": "true",
"tailscale.com/hostname": "tailscale-critical",
"tailscale.com/hostname": "custom-priority-class-name",
},
},
Spec: corev1.ServiceSpec{
@@ -920,14 +764,8 @@ func TestCustomPriorityClassName(t *testing.T) {
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "tailscale-critical",
priorityClassName: "custom-priority-class-name",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "custom-priority-class-name", "tailscale-critical"))
}
func TestDefaultLoadBalancer(t *testing.T) {
@@ -973,63 +811,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
}
expectEqual(t, fc, expectedSTS(o))
}
func TestProxyFirewallMode(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
tsFirewallMode: "nftables",
},
logger: zl.Sugar(),
isDefaultLoadBalancer: true,
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeLoadBalancer,
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
o := stsOpts{
name: shortName,
secretName: fullName,
hostname: "default-test",
firewallMode: "nftables",
}
expectEqual(t, fc, expectedSTS(o))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
}
func expectedSecret(name string) *corev1.Secret {
@@ -1080,51 +862,14 @@ func expectedHeadlessService(name string) *corev1.Service {
}
}
func expectedSTS(opts stsOpts) *appsv1.StatefulSet {
containerEnv := []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "TS_HOSTNAME", Value: opts.hostname},
}
annots := map[string]string{
"tailscale.com/operator-last-set-hostname": opts.hostname,
}
if opts.tailnetTargetIP != "" {
annots["tailscale.com/operator-last-set-ts-tailnet-target-ip"] = opts.tailnetTargetIP
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_IP",
Value: opts.tailnetTargetIP,
})
} else if opts.tailnetTargetFQDN != "" {
annots["tailscale.com/operator-last-set-ts-tailnet-target-fqdn"] = opts.tailnetTargetFQDN
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_FQDN",
Value: opts.tailnetTargetFQDN,
})
} else {
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_DEST_IP",
Value: "10.20.30.40",
})
annots["tailscale.com/operator-last-set-cluster-ip"] = "10.20.30.40"
}
if opts.firewallMode != "" {
containerEnv = append(containerEnv, corev1.EnvVar{
Name: "TS_DEBUG_FIREWALL_MODE",
Value: opts.firewallMode,
})
}
func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: opts.name,
Name: stsName,
Namespace: "operator-ns",
Labels: map[string]string{
"tailscale.com/managed": "true",
@@ -1138,20 +883,23 @@ func expectedSTS(opts stsOpts) *appsv1.StatefulSet {
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "1234-UID"},
},
ServiceName: opts.name,
ServiceName: stsName,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: annots,
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-cluster-ip": "10.20.30.40",
},
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: opts.priorityClassName,
PriorityClassName: priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
Image: "tailscale/tailscale",
Image: "busybox",
Command: []string{"/bin/sh"},
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
SecurityContext: &corev1.SecurityContext{
@@ -1163,7 +911,82 @@ func expectedSTS(opts stsOpts) *appsv1.StatefulSet {
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: containerEnv,
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: hostname},
{Name: "TS_DEST_IP", Value: "10.20.30.40"},
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
},
},
ImagePullPolicy: "Always",
},
},
},
},
},
}
}
func expectedEgressSTS(stsName, secretName, tailnetTargetIP, hostname, priorityClassName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: stsName,
Namespace: "operator-ns",
Labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "default",
"tailscale.com/parent-resource-type": "svc",
},
},
Spec: appsv1.StatefulSetSpec{
Replicas: ptr.To[int32](1),
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "1234-UID"},
},
ServiceName: stsName,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": hostname,
"tailscale.com/operator-last-set-ts-tailnet-target-ip": tailnetTargetIP,
},
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
Image: "busybox",
Command: []string{"/bin/sh"},
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
SecurityContext: &corev1.SecurityContext{
Privileged: ptr.To(true),
},
},
},
Containers: []corev1.Container{
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: hostname},
{Name: "TS_TAILNET_TARGET_IP", Value: tailnetTargetIP},
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
@@ -1303,16 +1126,6 @@ func expectRequeue(t *testing.T, sr *ServiceReconciler, ns, name string) {
}
}
type stsOpts struct {
name string
secretName string
hostname string
priorityClassName string
firewallMode string
tailnetTargetIP string
tailnetTargetFQDN string
}
type fakeTSClient struct {
sync.Mutex
keyRequests []tailscale.KeyCapabilities
@@ -1349,30 +1162,3 @@ func (c *fakeTSClient) Deleted() []string {
defer c.Unlock()
return c.deleted
}
func Test_isMagicDNSName(t *testing.T) {
tests := []struct {
in string
want bool
}{
{
in: "foo.tail4567.ts.net",
want: true,
},
{
in: "foo.tail4567.ts.net.",
want: true,
},
{
in: "foo.tail4567",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := isMagicDNSName(tt.in); got != tt.want {
t.Errorf("isMagicDNSName(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}

View File

@@ -21,8 +21,10 @@ import (
"k8s.io/client-go/transport"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
)
@@ -82,14 +84,13 @@ func parseAPIProxyMode() apiServerProxyMode {
// maybeLaunchAPIServerProxy launches the auth proxy, which is a small HTTP server
// that authenticates requests using the Tailscale LocalAPI and then proxies
// them to the kube-apiserver.
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server, mode apiServerProxyMode) {
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server) {
mode := parseAPIProxyMode()
if mode == apiserverProxyModeDisabled {
return
}
hostinfo.SetApp("k8s-operator-proxy")
startlog := zlog.Named("launchAPIProxy")
if mode == apiserverProxyModeNoAuth {
restConfig = rest.AnonymousClientConfig(restConfig)
}
cfg, err := restConfig.TransportConfig()
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
@@ -108,21 +109,21 @@ func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config,
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode)
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy").Infof, mode)
}
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type apiserverProxy struct {
log *zap.SugaredLogger
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
logf logger.Logf
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
}
func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
h.log.Errorf("failed to authenticate caller: %v", err)
h.logf("failed to authenticate caller: %v", err)
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
@@ -144,7 +145,7 @@ func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// are passed through to the Kubernetes API.
//
// It never returns.
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode) {
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf, mode apiServerProxyMode) {
if mode == apiserverProxyModeDisabled {
return
}
@@ -162,14 +163,13 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
log.Fatalf("could not get local client: %v", err)
}
ap := &apiserverProxy{
log: log,
lc: lc,
logf: logf,
lc: lc,
rp: &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
Director: func(r *http.Request) {
// Replace the URL with the Kubernetes APIServer.
r.Out.URL.Scheme = u.Scheme
r.Out.URL.Host = u.Host
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
if mode == apiserverProxyModeNoAuth {
// If we are not providing authentication, then we are just
// proxying to the Kubernetes API, so we don't need to do
@@ -184,18 +184,18 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
// Out of paranoia, remove all authentication headers that might
// have been set by the client.
r.Out.Header.Del("Authorization")
r.Out.Header.Del("Impersonate-Group")
r.Out.Header.Del("Impersonate-User")
r.Out.Header.Del("Impersonate-Uid")
for k := range r.Out.Header {
r.Header.Del("Authorization")
r.Header.Del("Impersonate-Group")
r.Header.Del("Impersonate-User")
r.Header.Del("Impersonate-Uid")
for k := range r.Header {
if strings.HasPrefix(k, "Impersonate-Extra-") {
r.Out.Header.Del(k)
r.Header.Del(k)
}
}
// Now add the impersonation headers that we want.
if err := addImpersonationHeaders(r.Out, log); err != nil {
if err := addImpersonationHeaders(r); err != nil {
panic("failed to add impersonation headers: " + err.Error())
}
},
@@ -212,7 +212,6 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: ap,
}
log.Infof("listening on %s", ln.Addr())
if err := hs.ServeTLS(ln, "", ""); err != nil {
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
}
@@ -235,8 +234,7 @@ type impersonateRule struct {
// addImpersonationHeaders adds the appropriate headers to r to impersonate the
// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed
// in the context by the apiserverProxy.
func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
log = log.With("remote", r.RemoteAddr)
func addImpersonationHeaders(r *http.Request) error {
who := whoIsFromRequest(r)
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
if err != nil {
@@ -254,26 +252,21 @@ func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
}
r.Header.Add("Impersonate-Group", group)
groupsAdded.Add(group)
log.Debugf("adding group impersonation header for user group %s", group)
}
}
if !who.Node.IsTagged() {
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
log.Debugf("adding user impersonation header for user %s", who.UserProfile.LoginName)
return nil
}
// "Impersonate-Group" requires "Impersonate-User" to be set, so we set it
// to the node FQDN for tagged nodes.
nodeName := strings.TrimSuffix(who.Node.Name, ".")
r.Header.Set("Impersonate-User", nodeName)
log.Debugf("adding user impersonation header for node name %s", nodeName)
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
// For legacy behavior (before caps), set the groups to the nodes tags.
if groupsAdded.Slice().Len() == 0 {
for _, tag := range who.Node.Tags {
r.Header.Add("Impersonate-Group", tag)
log.Debugf("adding group impersonation header for node tag %s", tag)
}
}
return nil

View File

@@ -10,17 +10,12 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"go.uber.org/zap"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
"tailscale.com/util/must"
)
func TestImpersonationHeaders(t *testing.T) {
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
emailish string
@@ -105,7 +100,7 @@ func TestImpersonationHeaders(t *testing.T) {
},
CapMap: tc.capMap,
})
addImpersonationHeaders(r, zl.Sugar())
addImpersonationHeaders(r)
if d := cmp.Diff(tc.wantHeaders, r.Header); d != "" {
t.Errorf("unexpected header (-want +got):\n%s", d)

View File

@@ -9,9 +9,7 @@ import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
@@ -21,7 +19,6 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apiserver/pkg/storage/names"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
@@ -47,18 +44,15 @@ const (
AnnotationHostname = "tailscale.com/hostname"
annotationTailnetTargetIPOld = "tailscale.com/ts-tailnet-target-ip"
AnnotationTailnetTargetIP = "tailscale.com/tailnet-ip"
//MagicDNS name of tailnet node.
AnnotationTailnetTargetFQDN = "tailscale.com/tailnet-fqdn"
// Annotations settable by users on ingresses.
AnnotationFunnel = "tailscale.com/funnel"
// Annotations set by the operator on pods to trigger restarts when the
// hostname, IP or FQDN changes.
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn"
// hostname or IP changes.
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
)
type tailscaleSTSConfig struct {
@@ -73,9 +67,6 @@ type tailscaleSTSConfig struct {
// Tailscale IP of a Tailscale service we are setting up egress for
TailnetTargetIP string
// Tailscale FQDN of a Tailscale service we are setting up egress for
TailnetTargetFQDN string
Hostname string
Tags []string // if empty, use defaultTags
}
@@ -88,14 +79,6 @@ type tailscaleSTSReconciler struct {
operatorNamespace string
proxyImage string
proxyPriorityClassName string
tsFirewallMode string
}
func (sts tailscaleSTSReconciler) validate() error {
if sts.tsFirewallMode != "" && !isValidFirewallMode(sts.tsFirewallMode) {
return fmt.Errorf("invalid proxy firewall mode %s, valid modes are iptables, nftables or unset", sts.tsFirewallMode)
}
return nil
}
// IsHTTPSEnabledOnTailnet reports whether HTTPS is enabled on the tailnet.
@@ -158,16 +141,10 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
return false, fmt.Errorf("getting device info: %w", err)
}
if id != "" {
logger.Debugf("deleting device %s from control", string(id))
// TODO: handle case where the device is already deleted, but the secret
// is still around.
if err := a.tsClient.DeleteDevice(ctx, string(id)); err != nil {
errResp := &tailscale.ErrResponse{}
if ok := errors.As(err, errResp); ok && errResp.Status == http.StatusNotFound {
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
} else {
return false, fmt.Errorf("deleting device: %w", err)
}
} else {
logger.Debugf("device %s deleted from control", string(id))
return false, fmt.Errorf("deleting device: %w", err)
}
}
@@ -183,39 +160,10 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
return true, nil
}
// maxStatefulSetNameLength is maximum length the StatefulSet name can
// have to NOT result in a too long value for controller-revision-hash
// label value (see https://github.com/kubernetes/kubernetes/issues/64023).
// controller-revision-hash label value consists of StatefulSet's name + hyphen + revision hash.
// Maximum label value length is 63 chars. Length of revision hash is 10 chars.
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/pkg/controller/history/controller_history.go#L90-L104
const maxStatefulSetNameLength = 63 - 10 - 1
// statefulSetNameBase accepts name of parent resource and returns a string in
// form ts-<portion-of-parentname>- that, when passed to Kubernetes name
// generation will NOT result in a StatefulSet name longer than 52 chars.
// This is done because of https://github.com/kubernetes/kubernetes/issues/64023.
func statefulSetNameBase(parent string) string {
base := fmt.Sprintf("ts-%s-", parent)
// Calculate what length name GenerateName returns for this base.
generator := names.SimpleNameGenerator
generatedName := generator.GenerateName(base)
if excess := len(generatedName) - maxStatefulSetNameLength; excess > 0 {
base = base[:len(base)-excess-1] // take extra char off to make space for hyphen
base = base + "-" // re-instate hyphen
}
return base
}
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
nameBase := statefulSetNameBase(sts.ParentResourceName)
hsvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
GenerateName: nameBase,
GenerateName: "ts-" + sts.ParentResourceName + "-",
Namespace: a.operatorNamespace,
Labels: sts.ChildResourceLabels,
},
@@ -343,10 +291,10 @@ func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string)
return key, nil
}
//go:embed deploy/manifests/proxy.yaml
//go:embed manifests/proxy.yaml
var proxyYaml []byte
//go:embed deploy/manifests/userspace-proxy.yaml
//go:embed manifests/userspace-proxy.yaml
var userspaceProxyYaml []byte
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) {
@@ -359,13 +307,6 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
}
for i := range ss.Spec.Template.Spec.InitContainers {
c := &ss.Spec.Template.Spec.InitContainers[i]
if c.Name == "sysctler" {
c.Image = a.proxyImage
break
}
}
}
container := &ss.Spec.Template.Spec.Containers[0]
container.Image = a.proxyImage
@@ -388,11 +329,7 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Name: "TS_TAILNET_TARGET_IP",
Value: sts.TailnetTargetIP,
})
} else if sts.TailnetTargetFQDN != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_TAILNET_TARGET_FQDN",
Value: sts.TailnetTargetFQDN,
})
} else if sts.ServeConfig != nil {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_SERVE_CONFIG",
@@ -416,13 +353,6 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
},
})
}
if a.tsFirewallMode != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_DEBUG_FIREWALL_MODE",
Value: a.tsFirewallMode,
},
)
}
ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name,
Namespace: a.operatorNamespace,
@@ -448,9 +378,6 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
if sts.TailnetTargetIP != "" {
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetIP] = sts.TailnetTargetIP
}
if sts.TailnetTargetFQDN != "" {
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetFQDN] = sts.TailnetTargetFQDN
}
ss.Spec.Template.Labels = map[string]string{
"app": sts.ParentResourceUID,
}
@@ -565,7 +492,3 @@ func nameForService(svc *corev1.Service) (string, error) {
}
return svc.Namespace + "-" + svc.Name, nil
}
func isValidFirewallMode(m string) bool {
return m == "auto" || m == "nftables" || m == "iptables"
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"testing"
)
// Test_statefulSetNameBase tests that parent name portion in a StatefulSet name
// base will be truncated if the parent name is longer than 43 chars to ensure
// that the total does not exceed 52 chars.
// How many chars need to be cut off parent name depends on an internal var in
// kube name generation code that can change at which point this test will break
// and need to be changed. This is okay as we do not rely on that value in
// code whilst being aware when it changes might still be useful.
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45.
// https://github.com/kubernetes/kubernetes/pull/116430
func Test_statefulSetNameBase(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{
name: "43 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "44 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xbo",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
},
{
name: "42 chars",
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x",
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x-",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := statefulSetNameBase(tt.in); got != tt.out {
t.Errorf("stsNamePrefix(%s) = %q, want %s", tt.in, got, tt.out)
}
})
}
}

View File

@@ -17,7 +17,6 @@ import (
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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"
"tailscale.com/util/clientmetric"
@@ -38,8 +37,6 @@ type ServiceReconciler struct {
// managedEgressProxies is a set of all egress proxies that we're currently
// managing. This is only used for metrics.
managedEgressProxies set.Slice[types.UID]
recorder record.EventRecorder
}
var (
@@ -81,8 +78,7 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
}
targetIP := a.tailnetTargetAnnotation(svc)
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" && targetFQDN == "" {
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" {
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
}
@@ -140,21 +136,6 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// This function adds a finalizer to svc, ensuring that we can handle orderly
// deprovisioning later.
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
// Run for proxy config related validations here as opposed to running
// them earlier. This is to prevent cleanup being blocked on a
// misconfigured proxy param.
if err := a.ssr.validate(); err != nil {
msg := fmt.Sprintf("unable to provision proxy resources: invalid config: %v", err)
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDCONFIG", msg)
a.logger.Error(msg)
return nil
}
if violations := validateService(svc); len(violations) > 0 {
msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", "))
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVCICE", msg)
a.logger.Error(msg)
return nil
}
hostname, err := nameForService(svc)
if err != nil {
return err
@@ -194,14 +175,6 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
sts.TailnetTargetIP = ip
a.managedEgressProxies.Add(svc.UID)
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
} else if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
sts.TailnetTargetFQDN = fqdn
a.managedEgressProxies.Add(svc.UID)
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
}
a.mu.Unlock()
@@ -210,7 +183,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return fmt.Errorf("failed to provision: %w", err)
}
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" {
if sts.TailnetTargetIP != "" {
// TODO (irbekrm): cluster.local is the default DNS name, but
// can be changed by users. Make this configurable or figure out
// how to discover the DNS name from within operator
@@ -269,19 +242,6 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
return nil
}
func validateService(svc *corev1.Service) []string {
violations := make([]string, 0)
if svc.Annotations[AnnotationTailnetTargetFQDN] != "" && svc.Annotations[AnnotationTailnetTargetIP] != "" {
violations = append(violations, "only one of annotations %s and %s can be set", AnnotationTailnetTargetIP, AnnotationTailnetTargetFQDN)
}
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
if !isMagicDNSName(fqdn) {
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn))
}
}
return violations
}
func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
// Headless services can't be exposed, since there is no ClusterIP to
// forward to.

View File

@@ -1,104 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"fmt"
"log"
"math/rand"
"net"
"net/netip"
"slices"
"inet.af/tcpproxy"
"tailscale.com/net/netutil"
)
type tcpRoundRobinHandler struct {
// To is a list of destination addresses to forward to.
// An entry may be either an IP address or a DNS name.
To []string
// DialContext is used to make the outgoing TCP connection.
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
// ReachableIPs enumerates the IP addresses this handler is reachable on.
ReachableIPs []netip.Addr
}
// ReachableOn returns the IP addresses this handler is reachable on.
func (h *tcpRoundRobinHandler) ReachableOn() []netip.Addr {
return h.ReachableIPs
}
func (h *tcpRoundRobinHandler) Handle(c net.Conn) {
addrPortStr := c.LocalAddr().String()
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr)
c.Close()
return
}
var p tcpproxy.Proxy
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
return netutil.NewOneConnListener(c, nil), nil
}
dest := h.To[rand.Intn(len(h.To))]
dial := &tcpproxy.DialProxy{
Addr: fmt.Sprintf("%s:%s", dest, port),
DialContext: h.DialContext,
}
p.AddRoute(addrPortStr, dial)
p.Start()
}
type tcpSNIHandler struct {
// Allowlist enumerates the FQDNs which may be proxied via SNI. An
// empty slice means all domains are permitted.
Allowlist []string
// DialContext is used to make the outgoing TCP connection.
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
// ReachableIPs enumerates the IP addresses this handler is reachable on.
ReachableIPs []netip.Addr
}
// ReachableOn returns the IP addresses this handler is reachable on.
func (h *tcpSNIHandler) ReachableOn() []netip.Addr {
return h.ReachableIPs
}
func (h *tcpSNIHandler) Handle(c net.Conn) {
addrPortStr := c.LocalAddr().String()
_, port, err := net.SplitHostPort(addrPortStr)
if err != nil {
log.Printf("tcpSNIHandler.Handle: bogus addrPort %q", addrPortStr)
c.Close()
return
}
var p tcpproxy.Proxy
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
return netutil.NewOneConnListener(c, nil), nil
}
p.AddSNIRouteFunc(addrPortStr, func(ctx context.Context, sniName string) (t tcpproxy.Target, ok bool) {
if len(h.Allowlist) > 0 {
// TODO(tom): handle subdomains
if slices.Index(h.Allowlist, sniName) < 0 {
return nil, false
}
}
return &tcpproxy.DialProxy{
Addr: net.JoinHostPort(sniName, port),
DialContext: h.DialContext,
}, true
})
p.Start()
}

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