Compare commits
1 Commits
irbekrm/le
...
bradfitz/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a794630f60 |
28
.github/workflows/checklocks.yml
vendored
28
.github/workflows/checklocks.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: checklocks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- '.github/workflows/checklocks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checklocks:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- name: Run checklocks vet
|
||||
# TODO: remove || true once we have applied checklocks annotations everywhere.
|
||||
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
|
||||
4
.github/workflows/golangci-lint.yml
vendored
4
.github/workflows/golangci-lint.yml
vendored
@@ -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
|
||||
|
||||
24
.github/workflows/kubemanifests.yaml
vendored
24
.github/workflows/kubemanifests.yaml
vendored
@@ -1,24 +0,0 @@
|
||||
name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- './cmd/k8s-operator/'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
# running
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
testchart:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build and lint Helm chart
|
||||
run: |
|
||||
eval `./tool/go run ./cmd/mkversion`
|
||||
./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart'
|
||||
./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz"
|
||||
44
.github/workflows/test.yml
vendored
44
.github/workflows/test.yml
vendored
@@ -22,7 +22,8 @@ on:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
# all PRs on all branches
|
||||
branches:
|
||||
- "*"
|
||||
merge_group:
|
||||
branches:
|
||||
- "main"
|
||||
@@ -38,26 +39,6 @@ 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
|
||||
@@ -66,13 +47,6 @@ jobs:
|
||||
- goarch: amd64
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '1/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '2/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '3/3'
|
||||
- goarch: "386" # thanks yaml
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -96,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 }}
|
||||
@@ -121,7 +94,6 @@ jobs:
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
- name: bench all
|
||||
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
env:
|
||||
@@ -190,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
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.53.0
|
||||
1.51.0
|
||||
|
||||
4
api.md
4
api.md
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
copy(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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestUpdateDomains(t *testing.T) {
|
||||
a := NewAppConnector(t.Logf, nil)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
rc := &routeCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// don't re-advertise routes that have already been advertised
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if !slices.Equal(rc.routes, wantRoutes) {
|
||||
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -35,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)
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
// browserSession holds data about a user's browser session
|
||||
// on the full management web client.
|
||||
type browserSession struct {
|
||||
// ID is the unique identifier for the session.
|
||||
// It is passed in the user's "TS-Web-Session" browser cookie.
|
||||
ID string
|
||||
SrcNode tailcfg.NodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthID string // from tailcfg.WebClientAuthResponse
|
||||
AuthURL string // from tailcfg.WebClientAuthResponse
|
||||
Created time.Time
|
||||
Authenticated bool
|
||||
}
|
||||
|
||||
// isAuthorized reports true if the given session is authorized
|
||||
// to be used by its associated user to access the full management
|
||||
// web client.
|
||||
//
|
||||
// isAuthorized is true only when s.Authenticated is true (i.e.
|
||||
// the user has authenticated the session) and the session is not
|
||||
// expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isAuthorized(now time.Time) bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case !s.Authenticated:
|
||||
return false // awaiting auth
|
||||
case s.isExpired(now):
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isExpired(now time.Time) bool {
|
||||
return !s.Created.IsZero() && now.After(s.expires())
|
||||
}
|
||||
|
||||
// expires reports when the given session expires.
|
||||
func (s *browserSession) expires() time.Time {
|
||||
return s.Created.Add(sessionCookieExpiry)
|
||||
}
|
||||
|
||||
var (
|
||||
errNoSession = errors.New("no-browser-session")
|
||||
errNotUsingTailscale = errors.New("not-using-tailscale")
|
||||
errTaggedRemoteSource = errors.New("tagged-remote-source")
|
||||
errTaggedLocalSource = errors.New("tagged-local-source")
|
||||
errNotOwner = errors.New("not-owner")
|
||||
)
|
||||
|
||||
// getSession retrieves the browser session associated with the request,
|
||||
// if one exists.
|
||||
//
|
||||
// An error is returned in any of the following cases:
|
||||
//
|
||||
// - (errNotUsingTailscale) The request was not made over tailscale.
|
||||
//
|
||||
// - (errNoSession) The request does not have a session.
|
||||
//
|
||||
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
|
||||
// Users must use their own user-owned devices to manage other nodes'
|
||||
// web clients.
|
||||
//
|
||||
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
|
||||
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
|
||||
// access to web clients.
|
||||
//
|
||||
// - (errNotOwner) The source is not the owner of this client (if the
|
||||
// client is user-owned). Only the owner is allowed to manage the
|
||||
// node via the web client.
|
||||
//
|
||||
// If no error is returned, the browserSession is always non-nil.
|
||||
// getTailscaleBrowserSession does not check whether the session has been
|
||||
// authorized by the user. Callers can use browserSession.isAuthorized.
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
|
||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case whoIsErr != nil:
|
||||
return nil, nil, errNotUsingTailscale
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, errNotOwner
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, whoIs, errNoSession
|
||||
} else if session.isExpired(s.timeNow()) {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
return session, whoIs, nil
|
||||
}
|
||||
|
||||
// newSession creates a new session associated with the given source user/node,
|
||||
// and stores it back to the session cache. Creating of a new session includes
|
||||
// generating a new auth URL from the control server.
|
||||
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
|
||||
d, err := s.getOrAwaitAuth(ctx, "", src.Node.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: src.Node.ID,
|
||||
SrcUser: src.UserProfile.ID,
|
||||
AuthID: d.ID,
|
||||
AuthURL: d.URL,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
s.browserSessions.Store(sid, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// awaitUserAuth blocks until the given session auth has been completed
|
||||
// by the user on the control server, then updates the session cache upon
|
||||
// completion. An error is returned if control auth failed for any reason.
|
||||
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return nil // already authorized
|
||||
}
|
||||
d, err := s.getOrAwaitAuth(ctx, session.AuthID, session.SrcNode)
|
||||
if err != nil {
|
||||
// Clean up the session. Doing this on any error from control
|
||||
// server to avoid the user getting stuck with a bad session
|
||||
// cookie.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return err
|
||||
}
|
||||
if d.Complete {
|
||||
session.Authenticated = d.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrAwaitAuth connects to the control server for user auth,
|
||||
// with the following behavior:
|
||||
//
|
||||
// 1. If authID is provided empty, a new auth URL is created on the control
|
||||
// server and reported back here, which can then be used to redirect the
|
||||
// user on the frontend.
|
||||
// 2. If authID is provided non-empty, the connection to control blocks until
|
||||
// the user has completed authenticating the associated auth URL,
|
||||
// or until ctx is canceled.
|
||||
func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
|
||||
type data struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed request: %s", body)
|
||||
}
|
||||
var authResp *tailcfg.WebClientAuthResponse
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
if _, ok := s.browserSessions.Load(cookie); !ok {
|
||||
return cookie, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
@@ -8,11 +8,9 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@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",
|
||||
@@ -20,7 +18,7 @@
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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
|
||||
@@ -16,13 +15,9 @@ export function apiFetch(
|
||||
): 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}` : ""}`
|
||||
@@ -67,10 +62,6 @@ function updateCsrfToken(r: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setSynoToken(token?: string) {
|
||||
synoToken = token
|
||||
}
|
||||
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,137 +1,124 @@
|
||||
import cx from "classnames"
|
||||
import React, { useEffect } from "react"
|
||||
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 LegacyClientView from "src/components/views/legacy-client-view"
|
||||
import LoginClientView from "src/components/views/login-client-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 { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
|
||||
export default function App() {
|
||||
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
||||
|
||||
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
|
||||
) : (
|
||||
<WebClient auth={auth} newSession={newSession} />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function WebClient({
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
// TODO(sonia): use isPosting value from useNodeData
|
||||
// to fill loading states.
|
||||
const { data, refreshData, updateNode } = useNodeData()
|
||||
useEffect(() => {
|
||||
refreshData()
|
||||
}, [auth, refreshData])
|
||||
|
||||
return !data ? (
|
||||
<div className="text-center py-14">Loading...</div>
|
||||
) : data.Status === "NeedsLogin" || data.Status === "NoState" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginClientView
|
||||
data={data}
|
||||
onLoginClick={() => updateNode({ Reauthenticate: true })}
|
||||
/>
|
||||
) : data.DebugMode !== "full" && data.DebugMode !== "login" ? (
|
||||
// Render legacy client interface.
|
||||
<>
|
||||
<LegacyClientView
|
||||
data={data}
|
||||
refreshData={refreshData}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
{/* TODO: add license to new client */}
|
||||
<Footer licensesURL={data.LicensesURL} />
|
||||
</>
|
||||
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} />
|
||||
) : (
|
||||
<ManageView {...data} />
|
||||
)}
|
||||
<Footer className="mt-20" licensesURL={data.LicensesURL} />
|
||||
</div>
|
||||
) : (
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<Header node={data} auth={auth} newSession={newSession} />
|
||||
<Router base={data.URLPrefix}>
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||
</Route>
|
||||
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
||||
<Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
|
||||
<Route path="/serve">{/* TODO */}Share local content</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 !== "/" && (
|
||||
<Link
|
||||
to="/"
|
||||
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
|
||||
>
|
||||
← Back to {node.DeviceName}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Footer({
|
||||
licensesURL,
|
||||
className,
|
||||
}: {
|
||||
licensesURL: string
|
||||
className?: string
|
||||
}) {
|
||||
function ManageView(props: NodeData) {
|
||||
return (
|
||||
<footer className={cx("container max-w-lg mx-auto text-center", className)}>
|
||||
<a
|
||||
className="text-xs text-gray-500 hover:text-gray-600"
|
||||
href={licensesURL}
|
||||
>
|
||||
Open Source Licenses
|
||||
</a>
|
||||
</footer>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
import { ReactComponent as Check } from "src/icons/check.svg"
|
||||
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
|
||||
import { ReactComponent as Search } from "src/icons/search.svg"
|
||||
|
||||
const noExitNode = "None"
|
||||
const runAsExitNode = "Run as exit node…"
|
||||
|
||||
export default function ExitNodeSelector({
|
||||
className,
|
||||
node,
|
||||
updateNode,
|
||||
disabled,
|
||||
}: {
|
||||
className?: string
|
||||
node: NodeData
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const [selected, setSelected] = useState(
|
||||
node.AdvertiseExitNode ? runAsExitNode : noExitNode
|
||||
)
|
||||
useEffect(() => {
|
||||
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
|
||||
}, [node])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: string) => {
|
||||
setOpen(false)
|
||||
if (item === selected) {
|
||||
return // no update
|
||||
}
|
||||
const old = selected
|
||||
setSelected(item)
|
||||
var update: NodeUpdate = {}
|
||||
switch (item) {
|
||||
case noExitNode:
|
||||
// turn off exit node
|
||||
update = { AdvertiseExitNode: false }
|
||||
break
|
||||
case runAsExitNode:
|
||||
// turn on exit node
|
||||
update = { AdvertiseExitNode: true }
|
||||
break
|
||||
}
|
||||
updateNode(update)?.catch(() => setSelected(old))
|
||||
},
|
||||
[setOpen, selected, setSelected]
|
||||
)
|
||||
// TODO: close on click outside
|
||||
// TODO(sonia): allow choosing to use another exit node
|
||||
|
||||
const [
|
||||
none, // not using exit nodes
|
||||
advertising, // advertising as exit node
|
||||
using, // using another exit node
|
||||
] = useMemo(
|
||||
() => [
|
||||
selected === noExitNode,
|
||||
selected === runAsExitNode,
|
||||
selected !== noExitNode && selected !== runAsExitNode,
|
||||
],
|
||||
[selected]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 === runAsExitNode ? "Running as exit node" : "None"}
|
||||
</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 cursor-pointer", {
|
||||
"bg-orange-400": advertising,
|
||||
"bg-indigo-400": using,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSelect(noExitNode)
|
||||
}}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
|
||||
<div className="w-full px-4 py-2 flex items-center gap-2.5">
|
||||
<Search />
|
||||
<input
|
||||
className="flex-1 leading-snug"
|
||||
placeholder="Search exit nodes…"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection
|
||||
items={[noExitNode, runAsExitNode]}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownSection({
|
||||
items,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
items: string[]
|
||||
selected?: string
|
||||
onSelect: (item: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full mt-1 pt-1 border-t border-gray-200">
|
||||
{items.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
||||
onClick={() => onSelect(v)}
|
||||
>
|
||||
<div className="leading-snug">{v}</div>
|
||||
{selected == v && <Check />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,52 +8,6 @@ import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
// purely to ease migration to the new React-based web client, and will
|
||||
// eventually be completely removed.
|
||||
|
||||
export default function LegacyClientView({
|
||||
data,
|
||||
refreshData,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
|
||||
<IP data={data} />
|
||||
{data.Status === "NeedsMachineAuth" ? (
|
||||
<div className="mb-4">
|
||||
This device is authorized, but needs approval from a network admin
|
||||
before it can connect to the network.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p>
|
||||
You are connected! Access this device over Tailscale using the
|
||||
device name or IP address above.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={cx("button button-medium mb-4", {
|
||||
"button-red": data.AdvertiseExitNode,
|
||||
"button-blue": !data.AdvertiseExitNode,
|
||||
})}
|
||||
id="enabled"
|
||||
onClick={() =>
|
||||
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
|
||||
}
|
||||
>
|
||||
{data.AdvertiseExitNode
|
||||
? "Stop advertising Exit Node"
|
||||
: "Advertise as Exit Node"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header({
|
||||
data,
|
||||
refreshData,
|
||||
@@ -230,3 +184,115 @@ export function IP(props: { data: NodeData }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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, learn 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>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
|
||||
import { ReactComponent as Eye } from "src/icons/eye.svg"
|
||||
import { ReactComponent as User } from "src/icons/user.svg"
|
||||
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>
|
||||
}) {
|
||||
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 className="text-black text-sm font-medium leading-tight">
|
||||
{!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 }
|
||||
)}
|
||||
onClick={handleSignInClick}
|
||||
>
|
||||
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-neutral-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.
|
||||
</p>
|
||||
))}
|
||||
{auth.viewerIdentity && (
|
||||
<>
|
||||
<hr />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { useLocation } from "wouter"
|
||||
import ACLTag from "../acl-tag"
|
||||
|
||||
export default function DeviceDetailsView({
|
||||
readonly,
|
||||
node,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-neutral-500 text-sm leading-tight text-center">
|
||||
Want even more details? Visit{" "}
|
||||
<a
|
||||
// TODO: pipe control serve url from backend
|
||||
href="https://login.tailscale.com/admin"
|
||||
target="_blank"
|
||||
className="text-indigo-700 text-sm"
|
||||
>
|
||||
this device’s page
|
||||
</a>{" "}
|
||||
in the admin console.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { Link } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
updateNode,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
}) {
|
||||
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}
|
||||
updateNode={updateNode}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<Link
|
||||
className="text-indigo-500 font-medium leading-snug"
|
||||
to="/details"
|
||||
>
|
||||
View device details →
|
||||
</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."
|
||||
/>
|
||||
<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,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
link: string
|
||||
body: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className={cx(
|
||||
"-mx-5 card flex justify-between items-center cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-neutral-800 font-medium leading-tight mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
|
||||
</div>
|
||||
<div>
|
||||
<ArrowRight className="ml-3" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
|
||||
/**
|
||||
* LoginClientView is rendered when the client is not authenticated
|
||||
* to a tailnet.
|
||||
*/
|
||||
export default function LoginClientView({
|
||||
data,
|
||||
onLoginClick,
|
||||
}: {
|
||||
data: NodeData
|
||||
onLoginClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<TailscaleIcon className="my-2 mb-8" />
|
||||
{data.IP ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device's key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
|
||||
<p className="text-gray-700">
|
||||
Get started by logging in to your Tailscale network.
|
||||
Or, learn more at{" "}
|
||||
<a href="https://tailscale.com/" className="link" target="_blank">
|
||||
tailscale.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
.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")
|
||||
// refresh data when auth complete
|
||||
apiFetch("/auth/session/wait", "GET").then(() => loadAuth())
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth()
|
||||
if (new URLSearchParams(window.location.search).get("check") == "now") {
|
||||
newSession()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
newSession,
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,9 @@ import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: NodeState
|
||||
Status: string
|
||||
DeviceName: string
|
||||
OS: string
|
||||
IP: string
|
||||
IPv6: string
|
||||
ID: string
|
||||
KeyExpiry: string
|
||||
KeyExpired: boolean
|
||||
AdvertiseExitNode: boolean
|
||||
AdvertiseRoutes: string
|
||||
LicensesURL: string
|
||||
@@ -20,23 +15,10 @@ export type NodeData = {
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
|
||||
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
type NodeState =
|
||||
| "NoState"
|
||||
| "NeedsLogin"
|
||||
| "NeedsMachineAuth"
|
||||
| "Stopped"
|
||||
| "Starting"
|
||||
| "Running"
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
@@ -93,7 +75,7 @@ export default function useNodeData() {
|
||||
: data.AdvertiseExitNode,
|
||||
}
|
||||
|
||||
return apiFetch("/data", "POST", update, { up: "true" })
|
||||
apiFetch("/data", "POST", update, { up: "true" })
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
setIsPosting(false)
|
||||
@@ -107,10 +89,7 @@ export default function useNodeData() {
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
.catch((err) => {
|
||||
alert("Failed operation: " + err.message)
|
||||
throw err
|
||||
})
|
||||
.catch((err) => alert("Failed operation: " + err.message))
|
||||
},
|
||||
[data]
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 236 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 203 B |
@@ -1,11 +0,0 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_15367_14595)">
|
||||
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_15367_14595">
|
||||
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 738 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 500 B |
@@ -1,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 |
@@ -2,37 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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",
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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/**"],
|
||||
|
||||
@@ -29,35 +29,23 @@ import (
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/licenses"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// ListenPort is the static port used for the web client when run inside tailscaled.
|
||||
// (5252 are the numbers above the letters "TSTS" on a qwerty keyboard.)
|
||||
const ListenPort = 5252
|
||||
|
||||
// Server is the backend server for a Tailscale web client.
|
||||
type Server struct {
|
||||
mode ServerMode
|
||||
lc *tailscale.LocalClient
|
||||
|
||||
logf logger.Logf
|
||||
lc *tailscale.LocalClient
|
||||
timeNow func() time.Time
|
||||
devMode bool
|
||||
tsDebugMode string
|
||||
|
||||
// devMode indicates that the server run with frontend assets
|
||||
// served by a Vite dev server, allowing for local development
|
||||
// on the web client frontend.
|
||||
devMode bool
|
||||
cgiMode bool
|
||||
pathPrefix string
|
||||
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
assetsCleanup func() // called from Server.Shutdown
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
|
||||
// browserSessions is an in-memory cache of browser sessions for the
|
||||
// full management web client, which is only accessible over Tailscale.
|
||||
@@ -73,40 +61,57 @@ type Server struct {
|
||||
browserSessions sync.Map
|
||||
}
|
||||
|
||||
// ServerMode specifies the mode of a running web.Server.
|
||||
type ServerMode string
|
||||
|
||||
const (
|
||||
// LoginServerMode serves a readonly login client for logging a
|
||||
// node into a tailnet, and viewing a readonly interface of the
|
||||
// node's current Tailscale settings.
|
||||
//
|
||||
// In this mode, API calls are authenticated via platform auth.
|
||||
LoginServerMode ServerMode = "login"
|
||||
|
||||
// ManageServerMode serves a management client for editing tailscale
|
||||
// settings of a node.
|
||||
//
|
||||
// This mode restricts the app to only being assessible over Tailscale,
|
||||
// and API calls are authenticated via browser sessions associated with
|
||||
// the source's Tailscale identity. If the source browser does not have
|
||||
// a valid session, a readonly version of the app is displayed.
|
||||
ManageServerMode ServerMode = "manage"
|
||||
|
||||
// LegacyServerMode serves the legacy web client, visible to users
|
||||
// prior to release of tailscale/corp#14335.
|
||||
LegacyServerMode ServerMode = "legacy"
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
// 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.StableNodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthURL string // control server URL for user to authenticate the session
|
||||
Authenticated time.Time // when zero, authentication not complete
|
||||
}
|
||||
|
||||
// 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 non-zero
|
||||
// (i.e. the user has authenticated the session) and the session
|
||||
// is not expired.
|
||||
// 2023-10-05: Sessions expire by default after 30 days.
|
||||
func (s *browserSession) isAuthorized() bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case s.Authenticated.IsZero():
|
||||
return false // awaiting auth
|
||||
case s.isExpired(): // TODO: add time field to server?
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default after 30 days.
|
||||
// If s.Authenticated is zero, isExpired reports false.
|
||||
func (s *browserSession) isExpired() bool {
|
||||
return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server?
|
||||
}
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
// Mode specifies the mode of web client being constructed.
|
||||
Mode ServerMode
|
||||
DevMode bool
|
||||
|
||||
// LoginOnly indicates that the server should only serve the minimal
|
||||
// login client and not the full web client.
|
||||
LoginOnly bool
|
||||
|
||||
// CGIMode indicates if the server is running as a CGI script.
|
||||
CGIMode bool
|
||||
@@ -117,50 +122,21 @@ type ServerOpts struct {
|
||||
// LocalClient is the tailscale.LocalClient to use for this web server.
|
||||
// If nil, a new one will be created.
|
||||
LocalClient *tailscale.LocalClient
|
||||
|
||||
// TimeNow optionally provides a time function.
|
||||
// time.Now is used as default.
|
||||
TimeNow func() time.Time
|
||||
|
||||
// Logf optionally provides a logger function.
|
||||
// log.Printf is used as default.
|
||||
Logf logger.Logf
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
// If err is empty, s is always non-nil.
|
||||
// ctx is only required to live the duration of the NewServer call,
|
||||
// and not the lifespan of the web server.
|
||||
func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
switch opts.Mode {
|
||||
case LoginServerMode, ManageServerMode, LegacyServerMode:
|
||||
// valid types
|
||||
case "":
|
||||
return nil, fmt.Errorf("must specify a Mode")
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid Mode provided")
|
||||
}
|
||||
// The provided context should live for the duration of the Server's lifetime.
|
||||
func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func()) {
|
||||
if opts.LocalClient == nil {
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
mode: opts.Mode,
|
||||
logf: opts.Logf,
|
||||
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
|
||||
devMode: opts.DevMode,
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
timeNow: opts.TimeNow,
|
||||
}
|
||||
if s.timeNow == nil {
|
||||
s.timeNow = time.Now
|
||||
}
|
||||
if s.logf == nil {
|
||||
s.logf = log.Printf
|
||||
}
|
||||
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
|
||||
|
||||
var metric string // clientmetric to report on startup
|
||||
s.tsDebugMode = s.debugMode()
|
||||
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
|
||||
|
||||
// Create handler for "/api" requests with CSRF protection.
|
||||
// We don't require secure cookies, since the web client is regularly used
|
||||
@@ -168,30 +144,31 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
// The client is secured by limiting the interface it listens on,
|
||||
// or by authenticating requests before they reach the web client.
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
if s.mode == LoginServerMode {
|
||||
if s.tsDebugMode == "login" {
|
||||
// For the login client, we don't serve the full web client API,
|
||||
// only the login endpoints.
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
metric = "web_login_client_initialization"
|
||||
s.lc.IncrementCounter(context.Background(), "web_login_client_initialization", 1)
|
||||
} else {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
||||
metric = "web_client_initialization"
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
|
||||
}
|
||||
|
||||
// Don't block startup on reporting metric.
|
||||
// Report in separate go routine with 5 second timeout.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.lc.IncrementCounter(ctx, metric, 1)
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
return s, cleanup
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() {
|
||||
s.logf("web.Server: shutting down")
|
||||
if s.assetsCleanup != nil {
|
||||
s.assetsCleanup()
|
||||
// debugMode returns the debug mode the web client is being run in.
|
||||
// The empty string is returned in the case that this instance is
|
||||
// not running in any debug mode.
|
||||
func (s *Server) debugMode() string {
|
||||
if !s.devMode {
|
||||
return "" // debug modes only available in dev
|
||||
}
|
||||
switch mode := os.Getenv("TS_DEBUG_WEB_CLIENT_MODE"); mode {
|
||||
case "login", "full": // valid debug modes
|
||||
return mode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ServeHTTP processes all requests for the Tailscale web client.
|
||||
@@ -207,142 +184,42 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mode == ManageServerMode {
|
||||
// In manage mode, requests must be sent directly to the bare Tailscale IP address.
|
||||
// If a request comes in on any other hostname, redirect.
|
||||
if s.requireTailscaleIP(w, r) {
|
||||
return // user was redirected
|
||||
}
|
||||
|
||||
// serve HTTP 204 on /ok requests as connectivity check
|
||||
if r.Method == httpm.GET && r.URL.Path == "/ok" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.devMode {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
// TODO: use CSP nonce or hash to eliminate need for unsafe-inline
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
switch {
|
||||
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
|
||||
s.serveAPIAuth(w, r) // serve auth status
|
||||
return
|
||||
case r.URL.Path == "/api/auth/session/new" && r.Method == httpm.GET:
|
||||
s.serveAPIAuthSessionNew(w, r) // create new session
|
||||
return
|
||||
case r.URL.Path == "/api/auth/session/wait" && r.Method == httpm.GET:
|
||||
s.serveAPIAuthSessionWait(w, r) // wait for session to be authorized
|
||||
return
|
||||
}
|
||||
if ok := s.authorizeRequest(w, r); !ok {
|
||||
http.Error(w, "not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Pass API requests through to the API handler.
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// requireTailscaleIP redirects an incoming request if the HTTP request was not made to a bare Tailscale IP address.
|
||||
// The request will be redirected to the Tailscale IP, port 5252, with the original request path.
|
||||
// This allows any custom hostname to be used to access the device, but protects against DNS rebinding attacks.
|
||||
// Returns true if the request has been fully handled, either be returning a redirect or an HTTP error.
|
||||
func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (handled bool) {
|
||||
const (
|
||||
ipv4ServiceHost = tsaddr.TailscaleServiceIPString
|
||||
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
|
||||
)
|
||||
// allow requests on quad-100 (or ipv6 equivalent)
|
||||
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
|
||||
return false
|
||||
}
|
||||
|
||||
st, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
if err != nil {
|
||||
s.logf("error getting status: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
|
||||
var ipv4 string // store the first IPv4 address we see for redirect later
|
||||
for _, ip := range st.Self.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
ipv4 = ip.String()
|
||||
}
|
||||
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
newURL := *r.URL
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
|
||||
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
// is authorized to be completed.
|
||||
// authorizePlatformRequest reports whether the request from the web client
|
||||
// is authorized to access the client for those platforms that support it.
|
||||
// It reports true if the request is authorized, and false otherwise.
|
||||
// authorizeRequest manages writing out any relevant authorization
|
||||
// authorizePlatformRequest manages writing out any relevant authorization
|
||||
// errors to the ResponseWriter itself.
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.mode == ManageServerMode { // client using tailscale auth
|
||||
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
// All requests must be made over tailscale.
|
||||
http.Error(w, "must access over tailscale", http.StatusUnauthorized)
|
||||
return false
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without browser session.
|
||||
return true
|
||||
case strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
// All other /api/ endpoints require a valid browser session.
|
||||
//
|
||||
// TODO(sonia): s.getSession calls whois again,
|
||||
// should try and use the above call instead of running another
|
||||
// localapi request.
|
||||
session, _, err := s.getSession(r)
|
||||
if err != nil || !session.isAuthorized(s.timeNow()) {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
// No additional auth on non-api (assets, index.html, etc).
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Client using system-specific auth.
|
||||
func authorizePlatformRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
authorized, _ := authorizeSynology(r)
|
||||
return authorized
|
||||
return authorizeSynology(w, r)
|
||||
case distro.QNAP:
|
||||
authorized, _ := authorizeQNAP(r)
|
||||
return authorized
|
||||
default:
|
||||
return true // no additional auth for this distro
|
||||
return authorizeQNAP(w, r)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serveLoginAPI serves requests for the web login client.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// The login client is run directly from client plugins,
|
||||
// so first authenticate and authorize the request for the host platform.
|
||||
if ok := authorizePlatformRequest(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
if r.URL.Path != "/api/data" { // only endpoint allowed for login client
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
@@ -352,146 +229,141 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case httpm.GET:
|
||||
// TODO(soniaappasamy): we may want a minimal node data response here
|
||||
s.serveGetNodeData(w, r)
|
||||
return
|
||||
case httpm.POST:
|
||||
// TODO(soniaappasamy): implement
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
type authType string
|
||||
|
||||
var (
|
||||
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
|
||||
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
|
||||
errNoSession = errors.New("no-browser-session")
|
||||
errNotUsingTailscale = errors.New("not-using-tailscale")
|
||||
errTaggedSource = errors.New("tagged-source")
|
||||
errNotOwner = errors.New("not-owner")
|
||||
)
|
||||
|
||||
// getTailscaleBrowserSession 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.
|
||||
//
|
||||
// - (errTaggedSource) The source is a tagged node. Users must use their
|
||||
// own user-owned devices to manage other nodes' 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.
|
||||
func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) {
|
||||
whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, errNotUsingTailscale
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, errTaggedSource
|
||||
}
|
||||
srcNode := whoIs.Node.StableID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case status.Self == nil:
|
||||
return nil, errors.New("missing self node in tailscale status")
|
||||
case !status.Self.IsTagged() && status.Self.UserID != srcUser:
|
||||
return nil, errNotOwner
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, 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, errNoSession
|
||||
} else if session.isExpired() {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, errNoSession
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
||||
CanManageNode bool `json:"canManageNode"`
|
||||
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
|
||||
OK bool `json:"ok"` // true when user has valid auth session
|
||||
AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take
|
||||
Error string `json:"error,omitempty"` // filled when Ok is false
|
||||
}
|
||||
|
||||
// viewerIdentity is the Tailscale identity of the source node
|
||||
// connected to this web client.
|
||||
type viewerIdentity struct {
|
||||
LoginName string `json:"loginName"`
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeIP string `json:"nodeIP"`
|
||||
ProfilePicURL string `json:"profilePicUrl,omitempty"`
|
||||
}
|
||||
|
||||
// serverAPIAuth handles requests to the /api/auth endpoint
|
||||
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
|
||||
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var resp authResponse
|
||||
|
||||
session, whois, err := s.getSession(r)
|
||||
session, err := s.getTailscaleBrowserSession(r)
|
||||
switch {
|
||||
case err != nil && errors.Is(err, errNotUsingTailscale):
|
||||
// not using tailscale, so perform platform auth
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
authorized, err := authorizeSynology(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
resp.AuthNeeded = synoAuth
|
||||
}
|
||||
case distro.QNAP:
|
||||
if _, err := authorizeQNAP(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// no additional auth for this distro
|
||||
}
|
||||
case err != nil && (errors.Is(err, errNotOwner) ||
|
||||
errors.Is(err, errNotUsingTailscale) ||
|
||||
errors.Is(err, errTaggedLocalSource) ||
|
||||
errors.Is(err, errTaggedRemoteSource)):
|
||||
// These cases are all restricted to the readonly view.
|
||||
// No auth action to take.
|
||||
resp.AuthNeeded = ""
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
// Any other error.
|
||||
resp = authResponse{OK: false, Error: err.Error()}
|
||||
case session == nil:
|
||||
// TODO(tailscale/corp#14335): Create a new auth path from control,
|
||||
// and store back to s.browserSessions and request cookie.
|
||||
case !session.isAuthorized():
|
||||
// TODO(tailscale/corp#14335): Check on the session auth path status from control,
|
||||
// and store back to s.browserSessions.
|
||||
default:
|
||||
resp = authResponse{OK: true}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
case session.isAuthorized(s.timeNow()):
|
||||
resp.CanManageNode = true
|
||||
resp.AuthNeeded = ""
|
||||
default:
|
||||
resp.AuthNeeded = tailscaleAuth
|
||||
}
|
||||
|
||||
if whois != nil {
|
||||
resp.ViewerIdentity = &viewerIdentity{
|
||||
LoginName: whois.UserProfile.LoginName,
|
||||
NodeName: whois.Node.Name,
|
||||
ProfilePicURL: whois.UserProfile.ProfilePicURL,
|
||||
}
|
||||
if addrs := whois.Node.Addresses; len(addrs) > 0 {
|
||||
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
|
||||
}
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
type newSessionAuthResponse struct {
|
||||
AuthURL string `json:"authUrl,omitempty"`
|
||||
}
|
||||
|
||||
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
||||
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
||||
session, whois, err := s.getSession(r)
|
||||
if err != nil && !errors.Is(err, errNoSession) {
|
||||
// Source associated with request not allowed to create
|
||||
// a session for this web client.
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
// Create a new session.
|
||||
// If one already existed, we return that authURL rather than creating a new one.
|
||||
session, err = s.newSession(r.Context(), whois)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, newSessionAuthResponse{AuthURL: session.AuthURL})
|
||||
}
|
||||
|
||||
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
||||
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
||||
session, _, err := s.getSession(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return // already authorized
|
||||
}
|
||||
if err := s.awaitUserAuth(r.Context(), session); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.tsDebugMode == "full" {
|
||||
// tailscale/corp#14335: Only restrict to tailscale auth in debug "full" web client mode.
|
||||
// TODO(sonia,will): Switch serveAPI over to always require TS auth when we're ready
|
||||
// to remove the debug flags.
|
||||
// For now, existing client uses platform auth (else case below).
|
||||
|
||||
if r.URL.Path == "/api/auth" {
|
||||
// Serve auth, which creates a new session for the user to authenticate,
|
||||
// in the case that the request doesn't already have one.
|
||||
s.serveTailscaleAuth(w, r)
|
||||
return
|
||||
}
|
||||
// For all other endpoints, require a valid session to proceed.
|
||||
session, err := s.getTailscaleBrowserSession(r)
|
||||
if err != nil || !session.isAuthorized() {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else if ok := authorizePlatformRequest(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
@@ -513,36 +385,20 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Status string
|
||||
DeviceName string
|
||||
TailnetName string // TLS cert name
|
||||
DomainName string
|
||||
IP string // IPv4
|
||||
IPv6 string
|
||||
OS string
|
||||
IPNVersion string
|
||||
|
||||
Profile tailcfg.UserProfile
|
||||
IsTagged bool
|
||||
Tags []string
|
||||
|
||||
KeyExpiry string // time.RFC3339
|
||||
KeyExpired bool
|
||||
|
||||
TUNMode bool
|
||||
IsSynology bool
|
||||
DSMVersion int // 6 or 7, if IsSynology=true
|
||||
IsUnraid bool
|
||||
UnraidToken string
|
||||
URLPrefix string // if set, the URL prefix the client is served behind
|
||||
|
||||
Profile tailcfg.UserProfile
|
||||
Status string
|
||||
DeviceName string
|
||||
IP string
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes string
|
||||
|
||||
LicensesURL string
|
||||
|
||||
DebugMode string // empty when not running in any debug mode
|
||||
LicensesURL string
|
||||
TUNMode bool
|
||||
IsSynology bool
|
||||
DSMVersion int // 6 or 7, if IsSynology=true
|
||||
IsUnraid bool
|
||||
UnraidToken string
|
||||
IPNVersion string
|
||||
DebugMode string // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -556,48 +412,24 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var debugMode string
|
||||
if s.mode == ManageServerMode {
|
||||
debugMode = "full"
|
||||
} else if s.mode == LoginServerMode {
|
||||
debugMode = "login"
|
||||
}
|
||||
profile := st.User[st.Self.UserID]
|
||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||
versionShort := strings.Split(st.Version, "-")[0]
|
||||
data := &nodeData{
|
||||
ID: st.Self.ID,
|
||||
Profile: profile,
|
||||
Status: st.BackendState,
|
||||
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
|
||||
TailnetName: st.CurrentTailnet.MagicDNSSuffix,
|
||||
DomainName: st.CurrentTailnet.Name,
|
||||
OS: st.Self.OS,
|
||||
IPNVersion: strings.Split(st.Version, "-")[0],
|
||||
Profile: st.User[st.Self.UserID],
|
||||
IsTagged: st.Self.IsTagged(),
|
||||
KeyExpired: st.Self.Expired,
|
||||
DeviceName: deviceName,
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
TUNMode: st.TUN,
|
||||
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
||||
DSMVersion: distro.DSMVersion(),
|
||||
IsUnraid: distro.Get() == distro.Unraid,
|
||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
||||
}
|
||||
for _, ip := range st.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
data.IP = ip.String()
|
||||
} else if ip.Is6() {
|
||||
data.IPv6 = ip.String()
|
||||
}
|
||||
if data.IP != "" && data.IPv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if st.Self.Tags != nil {
|
||||
data.Tags = st.Self.Tags.AsSlice()
|
||||
}
|
||||
if st.Self.KeyExpiry != nil {
|
||||
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
|
||||
IPNVersion: versionShort,
|
||||
DebugMode: s.tsDebugMode,
|
||||
}
|
||||
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertiseExitNode = true
|
||||
@@ -608,7 +440,14 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
data.AdvertiseRoutes += r.String()
|
||||
}
|
||||
}
|
||||
writeJSON(w, *data)
|
||||
if len(st.TailscaleIPs) != 0 {
|
||||
data.IP = st.TailscaleIPs[0].String()
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(*data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
type nodeUpdate struct {
|
||||
@@ -635,22 +474,6 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
isCurrentlyExitNode := slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV4) || slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV6)
|
||||
|
||||
if postData.AdvertiseExitNode != isCurrentlyExitNode {
|
||||
if postData.AdvertiseExitNode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||
}
|
||||
}
|
||||
|
||||
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -663,7 +486,7 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mp.Prefs.WantRunning = true
|
||||
mp.Prefs.AdvertiseRoutes = routes
|
||||
s.logf("Doing edit: %v", mp.Pretty())
|
||||
log.Printf("Doing edit: %v", mp.Pretty())
|
||||
|
||||
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -679,9 +502,9 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if postData.ForceLogout {
|
||||
logout = true
|
||||
}
|
||||
s.logf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
url, err := s.tailscaleUp(r.Context(), st, postData)
|
||||
s.logf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
@@ -866,12 +689,3 @@ func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
|
||||
http.StripPrefix(prefix, h).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -23,7 +22,6 @@ import (
|
||||
"tailscale.com/net/memnet"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
func TestQnapAuthnURL(t *testing.T) {
|
||||
@@ -126,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 {
|
||||
@@ -152,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 })
|
||||
// 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)
|
||||
@@ -241,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",
|
||||
@@ -303,527 +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 },
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
timeNow := time.Now()
|
||||
oneHourAgo := timeNow.Add(-time.Hour)
|
||||
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
}
|
||||
|
||||
successCookie := "ts-cookie-success"
|
||||
s.browserSessions.Store(successCookie, &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: testControlURL + testAuthPathSuccess,
|
||||
})
|
||||
failureCookie := "ts-cookie-failure"
|
||||
s.browserSessions.Store(failureCookie, &browserSession{
|
||||
ID: failureCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathError,
|
||||
AuthURL: testControlURL + testAuthPathError,
|
||||
})
|
||||
expiredCookie := "ts-cookie-expired"
|
||||
s.browserSessions.Store(expiredCookie, &browserSession{
|
||||
ID: expiredCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: sixtyDaysAgo,
|
||||
AuthID: "/a/old-auth-url",
|
||||
AuthURL: testControlURL + "/a/old-auth-url",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
cookie string // cookie attached to request
|
||||
wantNewCookie bool // want new cookie generated during request
|
||||
wantSession *browserSession // session associated w/ cookie after request
|
||||
|
||||
path string
|
||||
wantStatus int
|
||||
wantResp any
|
||||
}{
|
||||
{
|
||||
name: "no-session",
|
||||
path: "/api/auth",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{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,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
|
||||
r.RemoteAddr = remoteIP
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.serve(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
// Validate response status/data.
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
var gotResp string
|
||||
if res.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp = strings.Trim(string(body), "\n")
|
||||
}
|
||||
var wantResp string
|
||||
if tt.wantResp != nil {
|
||||
b, _ := json.Marshal(tt.wantResp)
|
||||
wantResp = string(b)
|
||||
}
|
||||
if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
|
||||
t.Errorf("wrong response; (-got+want):%v", diff)
|
||||
}
|
||||
// Validate cookie creation.
|
||||
sessionID := tt.cookie
|
||||
var gotCookie bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
gotCookie = true
|
||||
sessionID = c.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotCookie != tt.wantNewCookie {
|
||||
t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
|
||||
}
|
||||
// Validate browser session contents.
|
||||
var gotSesson *browserSession
|
||||
if s, ok := s.browserSessions.Load(sessionID); ok {
|
||||
gotSesson = s.(*browserSession)
|
||||
}
|
||||
if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
|
||||
// If requested, swap in the generated session ID before
|
||||
// comparing got/want.
|
||||
tt.wantSession.ID = sessionID
|
||||
}
|
||||
if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
|
||||
t.Errorf("wrong session; (-got+want):%v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireTailscaleIP(t *testing.T) {
|
||||
self := &ipnstate.PeerStatus{
|
||||
TailscaleIPs: []netip.Addr{
|
||||
netip.MustParseAddr("100.1.2.3"),
|
||||
netip.MustParseAddr("fd7a:115c::1234"),
|
||||
},
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self })
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
logf: t.Logf,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
wantHandled bool
|
||||
wantLocation string
|
||||
}{
|
||||
{
|
||||
name: "localhost",
|
||||
target: "http://localhost/",
|
||||
wantHandled: true,
|
||||
wantLocation: "http://100.1.2.3:5252/",
|
||||
},
|
||||
{
|
||||
name: "ipv4-no-port",
|
||||
target: "http://100.1.2.3/",
|
||||
wantHandled: true,
|
||||
wantLocation: "http://100.1.2.3:5252/",
|
||||
},
|
||||
{
|
||||
name: "ipv4-correct-port",
|
||||
target: "http://100.1.2.3:5252/",
|
||||
wantHandled: false,
|
||||
},
|
||||
{
|
||||
name: "ipv6-no-port",
|
||||
target: "http://[fd7a:115c::1234]/",
|
||||
wantHandled: true,
|
||||
wantLocation: "http://100.1.2.3:5252/",
|
||||
},
|
||||
{
|
||||
name: "ipv6-correct-port",
|
||||
target: "http://[fd7a:115c::1234]:5252/",
|
||||
wantHandled: false,
|
||||
},
|
||||
{
|
||||
name: "quad-100",
|
||||
target: "http://100.100.100.100/",
|
||||
wantHandled: false,
|
||||
},
|
||||
{
|
||||
name: "ipv6-service-addr",
|
||||
target: "http://[fd7a:115c:a1e0::53]/",
|
||||
wantHandled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.target, func(t *testing.T) {
|
||||
s.logf = t.Logf
|
||||
r := httptest.NewRequest(httpm.GET, tt.target, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handled := s.requireTailscaleIP(w, r)
|
||||
|
||||
if handled != tt.wantHandled {
|
||||
t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != tt.wantLocation {
|
||||
t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
testControlURL = "http://localhost:8080"
|
||||
testAuthPath = "/a/12345"
|
||||
testAuthPathSuccess = "/a/will-succeed"
|
||||
testAuthPathError = "/a/will-error"
|
||||
)
|
||||
|
||||
// mockLocalAPI constructs a test localapi handler that can be used
|
||||
// to simulate localapi responses without a functioning tailnet.
|
||||
//
|
||||
// self accepts a function that resolves to a self node status,
|
||||
// so that tests may swap out the /localapi/v0/status response
|
||||
// as desired.
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server {
|
||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
addr := r.URL.Query().Get("addr")
|
||||
if addr == "" {
|
||||
t.Fatalf("/whois call missing \"addr\" query")
|
||||
}
|
||||
if node := whoIs[addr]; node != nil {
|
||||
writeJSON(w, &node)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not a node", http.StatusUnauthorized)
|
||||
return
|
||||
case "/localapi/v0/status":
|
||||
writeJSON(w, ipnstate.Status{Self: self()})
|
||||
return
|
||||
case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
|
||||
type reqData struct {
|
||||
ID string
|
||||
Src tailcfg.NodeID
|
||||
}
|
||||
var data reqData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if data.Src == 0 {
|
||||
http.Error(w, "missing Src node", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var resp *tailcfg.WebClientAuthResponse
|
||||
if data.ID == "" {
|
||||
resp = &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath}
|
||||
} else if data.ID == testAuthPathSuccess {
|
||||
resp = &tailcfg.WebClientAuthResponse{Complete: true}
|
||||
} else if data.ID == testAuthPathError {
|
||||
http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
})}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,6 @@
|
||||
"@babel/highlight" "^7.22.10"
|
||||
chalk "^2.4.2"
|
||||
|
||||
"@babel/code-frame@^7.22.13":
|
||||
version "7.22.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
|
||||
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
|
||||
dependencies:
|
||||
"@babel/highlight" "^7.22.13"
|
||||
chalk "^2.4.2"
|
||||
|
||||
"@babel/compat-data@^7.22.9":
|
||||
version "7.22.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730"
|
||||
@@ -67,16 +59,6 @@
|
||||
"@jridgewell/trace-mapping" "^0.3.17"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/generator@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
|
||||
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
|
||||
dependencies:
|
||||
"@babel/types" "^7.23.0"
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
"@jridgewell/trace-mapping" "^0.3.17"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/helper-compilation-targets@^7.22.10":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz#01d648bbc25dd88f513d862ee0df27b7d4e67024"
|
||||
@@ -88,23 +70,18 @@
|
||||
lru-cache "^5.1.1"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-environment-visitor@^7.22.20":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
|
||||
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
|
||||
|
||||
"@babel/helper-environment-visitor@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98"
|
||||
integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==
|
||||
|
||||
"@babel/helper-function-name@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
|
||||
integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==
|
||||
"@babel/helper-function-name@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be"
|
||||
integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==
|
||||
dependencies:
|
||||
"@babel/template" "^7.22.15"
|
||||
"@babel/types" "^7.23.0"
|
||||
"@babel/template" "^7.22.5"
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/helper-hoist-variables@^7.22.5":
|
||||
version "7.22.5"
|
||||
@@ -150,11 +127,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
|
||||
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.22.20":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
|
||||
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193"
|
||||
@@ -183,41 +155,11 @@
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/highlight@^7.22.13":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
|
||||
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.22.20"
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/parser@^7.22.10", "@babel/parser@^7.22.5":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
|
||||
integrity sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==
|
||||
|
||||
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
|
||||
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
|
||||
|
||||
"@babel/runtime@^7.13.10":
|
||||
version "7.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
|
||||
integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.22.15":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
|
||||
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.22.13"
|
||||
"@babel/parser" "^7.22.15"
|
||||
"@babel/types" "^7.22.15"
|
||||
|
||||
"@babel/template@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
|
||||
@@ -228,18 +170,18 @@
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/traverse@^7.22.10":
|
||||
version "7.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
|
||||
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.10.tgz#20252acb240e746d27c2e82b4484f199cf8141aa"
|
||||
integrity sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.22.13"
|
||||
"@babel/generator" "^7.23.0"
|
||||
"@babel/helper-environment-visitor" "^7.22.20"
|
||||
"@babel/helper-function-name" "^7.23.0"
|
||||
"@babel/code-frame" "^7.22.10"
|
||||
"@babel/generator" "^7.22.10"
|
||||
"@babel/helper-environment-visitor" "^7.22.5"
|
||||
"@babel/helper-function-name" "^7.22.5"
|
||||
"@babel/helper-hoist-variables" "^7.22.5"
|
||||
"@babel/helper-split-export-declaration" "^7.22.6"
|
||||
"@babel/parser" "^7.23.0"
|
||||
"@babel/types" "^7.23.0"
|
||||
"@babel/parser" "^7.22.10"
|
||||
"@babel/types" "^7.22.10"
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
@@ -252,15 +194,6 @@
|
||||
"@babel/helper-validator-identifier" "^7.22.5"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.22.15", "@babel/types@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
||||
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.22.5"
|
||||
"@babel/helper-validator-identifier" "^7.22.20"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@cush/relative@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@cush/relative/-/relative-1.0.0.tgz#8cd1769bf9bde3bb27dac356b1bc94af40f6cc16"
|
||||
@@ -376,33 +309,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
|
||||
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
|
||||
|
||||
"@floating-ui/core@^1.4.2":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.0.tgz#5c05c60d5ae2d05101c3021c1a2a350ddc027f8c"
|
||||
integrity sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.1.3"
|
||||
|
||||
"@floating-ui/dom@^1.5.1":
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa"
|
||||
integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.4.2"
|
||||
"@floating-ui/utils" "^0.1.3"
|
||||
|
||||
"@floating-ui/react-dom@^2.0.0":
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.4.tgz#b076fafbdfeb881e1d86ae748b7ff95150e9f3ec"
|
||||
integrity sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.5.1"
|
||||
|
||||
"@floating-ui/utils@^0.1.3":
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
|
||||
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
|
||||
|
||||
"@jest/schemas@^29.6.0":
|
||||
version "29.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040"
|
||||
@@ -463,197 +369,6 @@
|
||||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@radix-ui/primitive@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd"
|
||||
integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-arrow@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d"
|
||||
integrity sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
||||
integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-context@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
|
||||
integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
|
||||
integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.0.3"
|
||||
|
||||
"@radix-ui/react-focus-guards@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
|
||||
integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-focus-scope@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
|
||||
integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-id@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
|
||||
integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-popover@^1.0.6":
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
|
||||
integrity sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.0.5"
|
||||
"@radix-ui/react-focus-guards" "1.0.1"
|
||||
"@radix-ui/react-focus-scope" "1.0.4"
|
||||
"@radix-ui/react-id" "1.0.1"
|
||||
"@radix-ui/react-popper" "1.1.3"
|
||||
"@radix-ui/react-portal" "1.0.4"
|
||||
"@radix-ui/react-presence" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
aria-hidden "^1.1.1"
|
||||
react-remove-scroll "2.5.5"
|
||||
|
||||
"@radix-ui/react-popper@1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"
|
||||
integrity sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@floating-ui/react-dom" "^2.0.0"
|
||||
"@radix-ui/react-arrow" "1.0.3"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
"@radix-ui/react-use-rect" "1.0.1"
|
||||
"@radix-ui/react-use-size" "1.0.1"
|
||||
"@radix-ui/rect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-portal@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
|
||||
integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@radix-ui/react-presence@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
|
||||
integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-primitive@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
|
||||
integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
|
||||
"@radix-ui/react-slot@1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
|
||||
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-callback-ref@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
|
||||
integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-use-controllable-state@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286"
|
||||
integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
|
||||
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-layout-effect@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
|
||||
integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-use-rect@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2"
|
||||
integrity sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/rect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-size@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2"
|
||||
integrity sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
|
||||
"@radix-ui/rect@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f"
|
||||
integrity sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@rollup/pluginutils@^5.0.2":
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.0.2.tgz#012b8f53c71e4f6f9cb317e311df1404f56e7a33"
|
||||
@@ -966,13 +681,6 @@ argparse@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||
|
||||
aria-hidden@^1.1.1:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954"
|
||||
integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
assertion-error@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
|
||||
@@ -1168,11 +876,6 @@ deep-eql@^4.1.2:
|
||||
dependencies:
|
||||
type-detect "^4.0.0"
|
||||
|
||||
detect-node-es@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
|
||||
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
|
||||
|
||||
didyoumean@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
|
||||
@@ -1299,14 +1002,9 @@ gensync@^1.0.0-beta.2:
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-func-name@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
|
||||
integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
|
||||
|
||||
get-nonce@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
|
||||
integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==
|
||||
|
||||
glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
@@ -1382,13 +1080,6 @@ inherits@2:
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
is-arrayish@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
|
||||
@@ -1477,7 +1168,7 @@ local-pkg@^0.4.3:
|
||||
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
|
||||
integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==
|
||||
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0:
|
||||
loose-envify@^1.1.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
@@ -1713,10 +1404,10 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||
|
||||
postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.31:
|
||||
version "8.4.31"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
|
||||
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||
postcss@^8.4.23, postcss@^8.4.27:
|
||||
version "8.4.27"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057"
|
||||
integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
picocolors "^1.0.0"
|
||||
@@ -1759,34 +1450,6 @@ react-is@^18.0.0:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||
|
||||
react-remove-scroll-bar@^2.3.3:
|
||||
version "2.3.4"
|
||||
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
|
||||
integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==
|
||||
dependencies:
|
||||
react-style-singleton "^2.2.1"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react-remove-scroll@2.5.5:
|
||||
version "2.5.5"
|
||||
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77"
|
||||
integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==
|
||||
dependencies:
|
||||
react-remove-scroll-bar "^2.3.3"
|
||||
react-style-singleton "^2.2.1"
|
||||
tslib "^2.1.0"
|
||||
use-callback-ref "^1.3.0"
|
||||
use-sidecar "^1.1.2"
|
||||
|
||||
react-style-singleton@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
||||
integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
|
||||
dependencies:
|
||||
get-nonce "^1.0.0"
|
||||
invariant "^2.2.4"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
@@ -1819,11 +1482,6 @@ recrawl-sync@^2.0.3:
|
||||
sucrase "^3.20.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
regenerator-runtime@^0.14.0:
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
|
||||
integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
|
||||
|
||||
resolve-from@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||
@@ -2024,11 +1682,6 @@ tslib@^1.9.3:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
tslib@^2.0.0, tslib@^2.1.0:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
type-detect@^4.0.0, type-detect@^4.0.5:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
|
||||
@@ -2052,26 +1705,6 @@ update-browserslist-db@^1.0.11:
|
||||
escalade "^3.1.1"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
use-callback-ref@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
|
||||
integrity sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sidecar@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
||||
integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
|
||||
dependencies:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||
|
||||
util-deprecate@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
@@ -2164,13 +1797,6 @@ why-is-node-running@^2.2.2:
|
||||
siginfo "^2.0.0"
|
||||
stackback "0.0.2"
|
||||
|
||||
wouter@^2.11.0:
|
||||
version "2.12.1"
|
||||
resolved "https://registry.yarnpkg.com/wouter/-/wouter-2.12.1.tgz#11d913324c6320b679873783acb15ea3523b8521"
|
||||
integrity sha512-G7a6JMSLSNcu6o8gdOfIzqxuo8Qx1qs+9rpVnlurH69angsSFPZP5gESNuVNeJct/MGpQg191pDo4HUjTx7IIQ==
|
||||
dependencies:
|
||||
use-sync-external-store "^1.0.0"
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
|
||||
@@ -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,77 +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:
|
||||
// Unraid runs from memory, updates must be installed via the Unraid
|
||||
// plugin manager to be persistent.
|
||||
// TODO(awly): implement Unraid updates using the 'plugin' CLI.
|
||||
return nil, false
|
||||
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.
|
||||
@@ -242,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 {
|
||||
@@ -262,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()
|
||||
@@ -285,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 {
|
||||
@@ -302,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]")) {
|
||||
@@ -415,15 +369,15 @@ func (up *Updater) updateDebLike() error {
|
||||
// we're not updating them:
|
||||
"-o", "APT::Get::List-Cleanup=0",
|
||||
)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+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
|
||||
}
|
||||
@@ -488,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
|
||||
@@ -537,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
|
||||
}
|
||||
@@ -608,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 {
|
||||
@@ -623,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)
|
||||
}
|
||||
@@ -654,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
|
||||
@@ -732,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")
|
||||
@@ -746,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 {
|
||||
@@ -760,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
|
||||
}
|
||||
@@ -768,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
|
||||
@@ -781,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
|
||||
@@ -826,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)
|
||||
@@ -835,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" {
|
||||
@@ -872,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) {
|
||||
@@ -927,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
|
||||
@@ -965,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 {
|
||||
@@ -993,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 {
|
||||
@@ -1069,77 +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 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)
|
||||
|
||||
@@ -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,113 +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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,13 @@
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -33,25 +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 := winutil.WTSGetActiveConsoleSessionId()
|
||||
if sessionID != 0xFFFFFFFF {
|
||||
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
defer token.Close()
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(exePath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Token: syscall.Token(token),
|
||||
HideWindow: true,
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
-----BEGIN ROOT PUBLIC KEY-----
|
||||
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
|
||||
Muw5GkO5mASsJ7k6kS+svfuanr6XcW9I7fPGtyqOTeI=
|
||||
-----END ROOT PUBLIC KEY-----
|
||||
@@ -19,7 +19,8 @@
|
||||
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
|
||||
// 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
|
||||
@@ -35,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.
|
||||
@@ -77,19 +84,10 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"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
|
||||
@@ -111,7 +109,7 @@ func main() {
|
||||
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),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", true),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
}
|
||||
|
||||
@@ -205,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)
|
||||
@@ -255,12 +253,10 @@ authLoop:
|
||||
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 != "" {
|
||||
@@ -299,13 +295,6 @@ authLoop:
|
||||
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)
|
||||
}
|
||||
}
|
||||
for {
|
||||
n, err := w.Next()
|
||||
if err != nil {
|
||||
@@ -326,7 +315,7 @@ authLoop:
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
|
||||
log.Printf("Installing proxy rules")
|
||||
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
|
||||
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs); err != nil {
|
||||
log.Fatalf("installing ingress proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +330,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs); err != nil {
|
||||
log.Fatalf("installing egress proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -396,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 {
|
||||
@@ -419,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.
|
||||
@@ -540,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 {
|
||||
@@ -685,12 +662,16 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, routes string
|
||||
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
|
||||
@@ -698,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
|
||||
@@ -729,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
|
||||
}
|
||||
|
||||
@@ -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,31 +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,
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -613,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))
|
||||
|
||||
@@ -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,6 +17,7 @@ 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+
|
||||
@@ -38,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+
|
||||
@@ -78,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
|
||||
@@ -111,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+
|
||||
@@ -147,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+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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 .Values.operatorConfig.podSecurityContext | 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 }}
|
||||
@@ -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 -}}
|
||||
@@ -1,88 +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
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: tailscale-operator-leaderelection
|
||||
namespace: kube-system
|
||||
rules:
|
||||
- apiGroups: ["coordination.k8s.io"]
|
||||
resources: ["leases"]
|
||||
resourceNames: ["tailscale-operator"]
|
||||
verbs: ["get", "update", "patch"]
|
||||
- apiGroups: ["coordination.k8s.io"]
|
||||
resources: ["leases"]
|
||||
verbs: ["create"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: tailscale-operator-leaderelection
|
||||
namespace: kube-system
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: {{ .Release.Namespace }}
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: tailscale-operator-leaderelection
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -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
|
||||
@@ -1,45 +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: ""
|
||||
logging: "info"
|
||||
hostname: "tailscale-operator"
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
|
||||
|
||||
# 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"
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +12,7 @@ spec:
|
||||
serviceAccountName: proxies
|
||||
initContainers:
|
||||
- name: sysctler
|
||||
image: busybox
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh"]
|
||||
@@ -37,17 +37,10 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/kubestore"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/lazy"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
const (
|
||||
leaderElectionLock = "tailscale-operator"
|
||||
)
|
||||
|
||||
type tsSetupFunc func() tsSetup
|
||||
|
||||
func main() {
|
||||
// Required to use our client API. We're fine with the instability since the
|
||||
// client lives in the same repo as this code.
|
||||
@@ -59,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
|
||||
@@ -74,168 +66,124 @@ 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()
|
||||
|
||||
c := managerConfig{
|
||||
apiServerProxyMode: mode,
|
||||
tsNamespace: tsNamespace,
|
||||
restConfig: restConfig,
|
||||
zlog: zlog,
|
||||
tsFirewallMode: tsFirewallMode,
|
||||
proxyImage: image,
|
||||
priorityClassName: priorityClassName,
|
||||
tags: tags,
|
||||
ts: initTSNet(zlog),
|
||||
}
|
||||
|
||||
runReconcilers(c)
|
||||
}
|
||||
|
||||
type managerConfig struct {
|
||||
// apiserver proxy mode
|
||||
apiServerProxyMode apiServerProxyMode
|
||||
tsNamespace string
|
||||
restConfig *rest.Config
|
||||
zlog *zap.SugaredLogger
|
||||
tsFirewallMode string
|
||||
proxyImage string
|
||||
priorityClassName string
|
||||
tags string
|
||||
ts tsSetupFunc
|
||||
}
|
||||
|
||||
type tsSetup struct {
|
||||
server *tsnet.Server
|
||||
client *tailscale.Client
|
||||
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) tsSetupFunc {
|
||||
ts := lazy.SyncFunc(func() tsSetup {
|
||||
var (
|
||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
|
||||
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
|
||||
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
|
||||
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
||||
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
||||
)
|
||||
startlog := zlog.Named("startup")
|
||||
if clientIDPath == "" || clientSecretPath == "" {
|
||||
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set")
|
||||
}
|
||||
clientID, err := os.ReadFile(clientIDPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client ID %q: %v", clientIDPath, err)
|
||||
}
|
||||
clientSecret, err := os.ReadFile(clientSecretPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client secret %q: %v", clientSecretPath, err)
|
||||
}
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: string(clientID),
|
||||
ClientSecret: string(clientSecret),
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.HTTPClient = credentials.Client(context.Background())
|
||||
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
|
||||
hostinfo.SetApp("k8s-operator")
|
||||
var (
|
||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
|
||||
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
|
||||
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
|
||||
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
||||
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
||||
)
|
||||
startlog := zlog.Named("startup")
|
||||
if clientIDPath == "" || clientSecretPath == "" {
|
||||
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set")
|
||||
}
|
||||
clientID, err := os.ReadFile(clientIDPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client ID %q: %v", clientIDPath, err)
|
||||
}
|
||||
clientSecret, err := os.ReadFile(clientSecretPath)
|
||||
if err != nil {
|
||||
startlog.Fatalf("reading client secret %q: %v", clientSecretPath, err)
|
||||
}
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: string(clientID),
|
||||
ClientSecret: string(clientSecret),
|
||||
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
|
||||
}
|
||||
tsClient := tailscale.NewClient("-", nil)
|
||||
tsClient.HTTPClient = credentials.Client(context.Background())
|
||||
|
||||
s := &tsnet.Server{
|
||||
Hostname: hostname,
|
||||
Logf: zlog.Named("tailscaled").Debugf,
|
||||
}
|
||||
if kubeSecret != "" {
|
||||
st, err := kubestore.New(logger.Discard, kubeSecret)
|
||||
if err != nil {
|
||||
startlog.Fatalf("creating kube store: %v", err)
|
||||
}
|
||||
s.Store = st
|
||||
}
|
||||
if err := s.Start(); err != nil {
|
||||
startlog.Fatalf("starting tailscale server: %v", err)
|
||||
}
|
||||
lc, err := s.LocalClient()
|
||||
s := &tsnet.Server{
|
||||
Hostname: hostname,
|
||||
Logf: zlog.Named("tailscaled").Debugf,
|
||||
}
|
||||
if kubeSecret != "" {
|
||||
st, err := kubestore.New(logger.Discard, kubeSecret)
|
||||
if err != nil {
|
||||
startlog.Fatalf("getting local client: %v", err)
|
||||
startlog.Fatalf("creating kube store: %v", err)
|
||||
}
|
||||
s.Store = st
|
||||
}
|
||||
if err := s.Start(); err != nil {
|
||||
startlog.Fatalf("starting tailscale server: %v", err)
|
||||
}
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
startlog.Fatalf("getting local client: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
loginDone := false
|
||||
machineAuthShown := false
|
||||
waitOnline:
|
||||
for {
|
||||
startlog.Debugf("querying tailscaled status")
|
||||
st, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
startlog.Fatalf("getting status: %v", err)
|
||||
ctx := context.Background()
|
||||
loginDone := false
|
||||
machineAuthShown := false
|
||||
waitOnline:
|
||||
for {
|
||||
startlog.Debugf("querying tailscaled status")
|
||||
st, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
startlog.Fatalf("getting status: %v", err)
|
||||
}
|
||||
switch st.BackendState {
|
||||
case "Running":
|
||||
break waitOnline
|
||||
case "NeedsLogin":
|
||||
if loginDone {
|
||||
break
|
||||
}
|
||||
switch st.BackendState {
|
||||
case "Running":
|
||||
break waitOnline
|
||||
case "NeedsLogin":
|
||||
if loginDone {
|
||||
break
|
||||
}
|
||||
caps := tailscale.KeyCapabilities{
|
||||
Devices: tailscale.KeyDeviceCapabilities{
|
||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
||||
Reusable: false,
|
||||
Preauthorized: true,
|
||||
Tags: strings.Split(operatorTags, ","),
|
||||
},
|
||||
caps := tailscale.KeyCapabilities{
|
||||
Devices: tailscale.KeyDeviceCapabilities{
|
||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
||||
Reusable: false,
|
||||
Preauthorized: true,
|
||||
Tags: strings.Split(operatorTags, ","),
|
||||
},
|
||||
}
|
||||
authkey, _, err := tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
startlog.Fatalf("creating operator authkey: %v", err)
|
||||
}
|
||||
if err := lc.Start(ctx, ipn.Options{
|
||||
AuthKey: authkey,
|
||||
}); err != nil {
|
||||
startlog.Fatalf("starting tailscale: %v", err)
|
||||
}
|
||||
if err := lc.StartLoginInteractive(ctx); err != nil {
|
||||
startlog.Fatalf("starting login: %v", err)
|
||||
}
|
||||
startlog.Debugf("requested login by authkey")
|
||||
loginDone = true
|
||||
case "NeedsMachineAuth":
|
||||
if !machineAuthShown {
|
||||
startlog.Infof("Machine approval required, please visit the admin panel to approve")
|
||||
machineAuthShown = true
|
||||
}
|
||||
default:
|
||||
startlog.Debugf("waiting for tailscale to start: %v", st.BackendState)
|
||||
},
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
authkey, _, err := tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
startlog.Fatalf("creating operator authkey: %v", err)
|
||||
}
|
||||
if err := lc.Start(ctx, ipn.Options{
|
||||
AuthKey: authkey,
|
||||
}); err != nil {
|
||||
startlog.Fatalf("starting tailscale: %v", err)
|
||||
}
|
||||
if err := lc.StartLoginInteractive(ctx); err != nil {
|
||||
startlog.Fatalf("starting login: %v", err)
|
||||
}
|
||||
startlog.Debugf("requested login by authkey")
|
||||
loginDone = true
|
||||
case "NeedsMachineAuth":
|
||||
if !machineAuthShown {
|
||||
startlog.Infof("Machine approval required, please visit the admin panel to approve")
|
||||
machineAuthShown = true
|
||||
}
|
||||
default:
|
||||
startlog.Debugf("waiting for tailscale to start: %v", st.BackendState)
|
||||
}
|
||||
return tsSetup{
|
||||
// TODO: server needs closing
|
||||
server: s,
|
||||
client: tsClient,
|
||||
}
|
||||
})
|
||||
return ts
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return s, tsClient
|
||||
}
|
||||
|
||||
// runReconcilers starts the controller-runtime manager and registers the
|
||||
// ServiceReconciler. It blocks forever.
|
||||
func runReconcilers(c managerConfig) {
|
||||
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)
|
||||
)
|
||||
startlog := c.zlog.Named("startReconcilers")
|
||||
startlog := zlog.Named("startReconcilers")
|
||||
// For secrets and statefulsets, we only get permission to touch the objects
|
||||
// in the controller's own namespace. This cannot be expressed by
|
||||
// .Watches(...) below, instead you have to add a per-type field selector to
|
||||
@@ -243,13 +191,9 @@ func runReconcilers(c managerConfig) {
|
||||
// implicitly filter what parts of the world the builder code gets to see at
|
||||
// all.
|
||||
nsFilter := cache.ByObject{
|
||||
Field: client.InNamespace(c.tsNamespace).AsSelector(),
|
||||
Field: client.InNamespace(tsNamespace).AsSelector(),
|
||||
}
|
||||
mgr, err := manager.New(c.restConfig, manager.Options{
|
||||
LeaderElectionNamespace: "kube-system",
|
||||
LeaderElection: true,
|
||||
LeaderElectionReleaseOnCancel: true,
|
||||
LeaderElectionID: leaderElectionLock,
|
||||
mgr, err := manager.New(restConfig, manager.Options{
|
||||
Cache: cache.Options{
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
@@ -266,12 +210,12 @@ func runReconcilers(c managerConfig) {
|
||||
eventRecorder := mgr.GetEventRecorderFor("tailscale-operator")
|
||||
ssr := &tailscaleSTSReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
ts: c.ts,
|
||||
defaultTags: strings.Split(c.tags, ","),
|
||||
operatorNamespace: c.tsNamespace,
|
||||
proxyImage: c.proxyImage,
|
||||
proxyPriorityClassName: c.priorityClassName,
|
||||
tsFirewallMode: c.tsFirewallMode,
|
||||
tsnetServer: s,
|
||||
tsClient: tsClient,
|
||||
defaultTags: strings.Split(tags, ","),
|
||||
operatorNamespace: tsNamespace,
|
||||
proxyImage: image,
|
||||
proxyPriorityClassName: priorityClassName,
|
||||
}
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
@@ -282,9 +226,8 @@ func runReconcilers(c managerConfig) {
|
||||
Complete(&ServiceReconciler{
|
||||
ssr: ssr,
|
||||
Client: mgr.GetClient(),
|
||||
logger: c.zlog.Named("service-reconciler"),
|
||||
logger: zlog.Named("service-reconciler"),
|
||||
isDefaultLoadBalancer: isDefaultLoadBalancer,
|
||||
recorder: eventRecorder,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create controller: %v", err)
|
||||
@@ -300,15 +243,12 @@ func runReconcilers(c managerConfig) {
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: c.zlog.Named("ingress-reconciler"),
|
||||
logger: zlog.Named("ingress-reconciler"),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create controller: %v", err)
|
||||
}
|
||||
|
||||
if err := maybeConfigureAPIServerProxy(c, mgr); err != nil {
|
||||
startlog.Fatalf("error configuring api server proxy: %v", err)
|
||||
}
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
startlog.Fatalf("could not start manager: %v", err)
|
||||
|
||||
@@ -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
|
||||
@@ -207,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",
|
||||
@@ -237,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
|
||||
@@ -322,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",
|
||||
@@ -427,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
|
||||
@@ -477,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{
|
||||
@@ -560,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
|
||||
@@ -628,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{
|
||||
@@ -703,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",
|
||||
@@ -782,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(),
|
||||
}
|
||||
@@ -799,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{
|
||||
@@ -811,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) {
|
||||
@@ -864,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 {
|
||||
@@ -971,44 +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 {
|
||||
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",
|
||||
@@ -1022,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{
|
||||
@@ -1047,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"},
|
||||
@@ -1187,15 +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
|
||||
}
|
||||
|
||||
type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/transport"
|
||||
"sigs.k8s.io/controller-runtime/pkg/manager"
|
||||
"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"
|
||||
@@ -79,23 +81,19 @@ func parseAPIProxyMode() apiServerProxyMode {
|
||||
return apiserverProxyModeDisabled
|
||||
}
|
||||
|
||||
// TODO: cleanup, return errors etc
|
||||
// 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 maybeConfigureAPIServerProxy(c managerConfig, mgr manager.Manager) error {
|
||||
if c.apiServerProxyMode == apiserverProxyModeDisabled {
|
||||
return nil
|
||||
}
|
||||
startlog := mgr.GetLogger().WithName("lauchAPIProxy")
|
||||
startlog.Info("configuring api-server proxy")
|
||||
restConfig := c.restConfig
|
||||
if c.apiServerProxyMode == apiserverProxyModeNoAuth {
|
||||
restConfig = rest.AnonymousClientConfig(c.restConfig)
|
||||
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")
|
||||
cfg, err := restConfig.TransportConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving transport config: %v", err)
|
||||
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
|
||||
}
|
||||
|
||||
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
|
||||
@@ -103,38 +101,15 @@ func maybeConfigureAPIServerProxy(c managerConfig, mgr manager.Manager) error {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig, err = transport.TLSConfigFor(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting transport config: %v", err)
|
||||
startlog.Fatalf("could not get transport.TLSConfigFor(): %v", err)
|
||||
}
|
||||
tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
|
||||
|
||||
rt, err := transport.HTTPWrappersForConfig(cfg, tr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting http wrapper: %v", err)
|
||||
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
|
||||
}
|
||||
pr := &proxyRunnable{
|
||||
ts: c.ts,
|
||||
rt: rt,
|
||||
logf: mgr.GetLogger().WithName("proxyRunnable").Info,
|
||||
mode: c.apiServerProxyMode,
|
||||
}
|
||||
if err := mgr.Add(pr); err != nil {
|
||||
return fmt.Errorf("error adding proxy runnable:%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ manager.LeaderElectionRunnable = &proxyRunnable{}
|
||||
var _ manager.Runnable = &proxyRunnable{}
|
||||
|
||||
type proxyRunnable struct {
|
||||
ts tsSetupFunc
|
||||
rt http.RoundTripper
|
||||
mode apiServerProxyMode
|
||||
logf logger.Logf
|
||||
}
|
||||
|
||||
func (pr *proxyRunnable) NeedLeaderElection() bool {
|
||||
return true
|
||||
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy").Infof, mode)
|
||||
}
|
||||
|
||||
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
|
||||
@@ -170,39 +145,32 @@ 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, logf logger.Logf, mode apiServerProxyMode) {
|
||||
func (p *proxyRunnable) Start(ctx context.Context) error {
|
||||
p.logf("starting proxy runnable")
|
||||
if p.mode == apiserverProxyModeDisabled {
|
||||
return nil
|
||||
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf, mode apiServerProxyMode) {
|
||||
if mode == apiserverProxyModeDisabled {
|
||||
return
|
||||
}
|
||||
server := p.ts().server
|
||||
ln, err := server.Listen("tcp", ":443")
|
||||
ln, err := s.Listen("tcp", ":443")
|
||||
if err != nil {
|
||||
log.Fatalf("could not listen on :443: %v", err)
|
||||
}
|
||||
p.logf("listening on", "addr", ln.Addr())
|
||||
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
if err != nil {
|
||||
log.Fatalf("runAPIServerProxy: failed to parse URL %v", err)
|
||||
}
|
||||
p.logf("will be forwarding requests to", "url", u)
|
||||
|
||||
lc, err := server.LocalClient()
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("could not get local client: %v", err)
|
||||
}
|
||||
|
||||
ap := &apiserverProxy{
|
||||
logf: p.logf,
|
||||
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
|
||||
if p.mode == apiserverProxyModeNoAuth {
|
||||
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
|
||||
// anything else.
|
||||
@@ -216,24 +184,22 @@ func (p *proxyRunnable) Start(ctx context.Context) error {
|
||||
|
||||
// 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); err != nil {
|
||||
if err := addImpersonationHeaders(r); err != nil {
|
||||
panic("failed to add impersonation headers: " + err.Error())
|
||||
}
|
||||
p.logf("will be forwarding with headers", "user", r.Out.Header.Get("Impersonate-User"))
|
||||
p.logf("will be forwarding with headers", "group", r.Out.Header.Get("Impersonate-Group"))
|
||||
},
|
||||
Transport: p.rt,
|
||||
Transport: rt,
|
||||
},
|
||||
}
|
||||
hs := &http.Server{
|
||||
@@ -246,12 +212,9 @@ func (p *proxyRunnable) Start(ctx context.Context) error {
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
|
||||
Handler: ap,
|
||||
}
|
||||
p.logf("about to serve TLS")
|
||||
if err := hs.ServeTLS(ln, "", ""); err != nil {
|
||||
p.logf("error serving: %v", err)
|
||||
return fmt.Errorf("runAPIServerProxy: failed to serve %v", err)
|
||||
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const capabilityName = "https://tailscale.com/cap/kubernetes"
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +24,7 @@ import (
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -74,25 +73,17 @@ type tailscaleSTSConfig struct {
|
||||
|
||||
type tailscaleSTSReconciler struct {
|
||||
client.Client
|
||||
ts tsSetupFunc
|
||||
tsnetServer *tsnet.Server
|
||||
tsClient tsClient
|
||||
defaultTags []string
|
||||
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.
|
||||
func (a *tailscaleSTSReconciler) IsHTTPSEnabledOnTailnet() bool {
|
||||
server := a.ts().server
|
||||
return len(server.CertDomains()) > 0
|
||||
return len(a.tsnetServer.CertDomains()) > 0
|
||||
}
|
||||
|
||||
// Provision ensures that the StatefulSet for the given service is running and
|
||||
@@ -150,17 +141,10 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, logger *zap.Sugare
|
||||
return false, fmt.Errorf("getting device info: %w", err)
|
||||
}
|
||||
if id != "" {
|
||||
client := a.ts().client
|
||||
logger.Debugf("deleting device %s from control", string(id))
|
||||
if err := client.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))
|
||||
// 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 {
|
||||
return false, fmt.Errorf("deleting device: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,19 +284,17 @@ func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string)
|
||||
},
|
||||
}
|
||||
|
||||
client := a.ts().client
|
||||
|
||||
key, _, err := client.CreateKey(ctx, caps)
|
||||
key, _, err := a.tsClient.CreateKey(ctx, caps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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) {
|
||||
@@ -325,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
|
||||
@@ -378,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,
|
||||
@@ -524,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"
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
@@ -139,15 +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 etc 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
|
||||
}
|
||||
hostname, err := nameForService(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/memnet"
|
||||
)
|
||||
|
||||
func echoConnOnce(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
b := make([]byte, 256)
|
||||
n, err := conn.Read(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := conn.Write(b[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPRoundRobinHandler(t *testing.T) {
|
||||
h := tcpRoundRobinHandler{
|
||||
To: []string{"yeet.com"},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
t.Errorf("network = %s, want %s", network, "tcp")
|
||||
}
|
||||
if addr != "yeet.com:22" {
|
||||
t.Errorf("addr = %s, want %s", addr, "yeet.com:22")
|
||||
}
|
||||
|
||||
c, s := memnet.NewConn("outbound", 1024)
|
||||
go echoConnOnce(s)
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
|
||||
cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:22"), 1024)
|
||||
h.Handle(sSock)
|
||||
|
||||
// Test data write and read, the other end will echo back
|
||||
// a single stanza
|
||||
want := "hello"
|
||||
if _, err := io.WriteString(cSock, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// The other end closed the socket after the first echo, so
|
||||
// any following read should error.
|
||||
io.WriteString(cSock, "deadass heres some data on god fr")
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err == nil {
|
||||
t.Error("read succeeded on closed socket")
|
||||
}
|
||||
}
|
||||
|
||||
// Capture of first TCP data segment for a connection to https://pkgs.tailscale.com
|
||||
const tlsStart = `45000239ff1840004006f9f5c0a801f2
|
||||
c726b5efcf9e01bbe803b21394e3b752
|
||||
801801f641dc00000101080ade3474f2
|
||||
2fb93ee71603010200010001fc030303
|
||||
c3acbd19d2624765bb19af4bce03365e
|
||||
1d197f5bb939cdadeff26b0f8e7a0620
|
||||
295b04127b82bae46aac4ff58cffef25
|
||||
eba75a4b7a6de729532c411bd9dd0d2c
|
||||
00203a3a130113021303c02bc02fc02c
|
||||
c030cca9cca8c013c014009c009d002f
|
||||
003501000193caca0000000a000a0008
|
||||
1a1a001d001700180010000e000c0268
|
||||
3208687474702f312e31002b0007062a
|
||||
2a03040303ff01000100000d00120010
|
||||
04030804040105030805050108060601
|
||||
000b00020100002300000033002b0029
|
||||
1a1a000100001d0020d3c76bef062979
|
||||
a812ce935cfb4dbe6b3a84dc5ba9226f
|
||||
23b0f34af9d1d03b4a001b0003020002
|
||||
00120000446900050003026832000000
|
||||
170015000012706b67732e7461696c73
|
||||
63616c652e636f6d002d000201010005
|
||||
00050100000000001700003a3a000100
|
||||
0015002d000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
00000000000000000000000000000000
|
||||
0000290094006f0069e76f2016f963ad
|
||||
38c8632d1f240cd75e00e25fdef295d4
|
||||
7042b26f3a9a543b1c7dc74939d77803
|
||||
20527d423ff996997bda2c6383a14f49
|
||||
219eeef8a053e90a32228df37ddbe126
|
||||
eccf6b085c93890d08341d819aea6111
|
||||
0d909f4cd6b071d9ea40618e74588a33
|
||||
90d494bbb5c3002120d5a164a16c9724
|
||||
c9ef5e540d8d6f007789a7acf9f5f16f
|
||||
bf6a1907a6782ed02b`
|
||||
|
||||
func fakeSNIHeader() []byte {
|
||||
b, err := hex.DecodeString(strings.Replace(tlsStart, "\n", "", -1))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b[0x34:] // trim IP + TCP header
|
||||
}
|
||||
|
||||
func TestTCPSNIHandler(t *testing.T) {
|
||||
h := tcpSNIHandler{
|
||||
Allowlist: []string{"pkgs.tailscale.com"},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
t.Errorf("network = %s, want %s", network, "tcp")
|
||||
}
|
||||
if addr != "pkgs.tailscale.com:443" {
|
||||
t.Errorf("addr = %s, want %s", addr, "pkgs.tailscale.com:443")
|
||||
}
|
||||
|
||||
c, s := memnet.NewConn("outbound", 1024)
|
||||
go echoConnOnce(s)
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
|
||||
cSock, sSock := memnet.NewTCPConn(netip.MustParseAddrPort("10.64.1.2:22"), netip.MustParseAddrPort("10.64.1.2:443"), 1024)
|
||||
h.Handle(sSock)
|
||||
|
||||
// Fake a TLS handshake record with an SNI in it.
|
||||
if _, err := cSock.Write(fakeSNIHeader()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test read, the other end will echo back
|
||||
// a single stanza, which is at least the beginning of the SNI header.
|
||||
want := fakeSNIHeader()[:5]
|
||||
if _, err := cSock.Write(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadAtLeast(cSock, got, len(got)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/nettype"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
|
||||
|
||||
// target describes the predicates which route some inbound
|
||||
// traffic to the app connector to a specific handler.
|
||||
type target struct {
|
||||
Dest netip.Prefix
|
||||
Matching tailcfg.ProtoPortRange
|
||||
}
|
||||
|
||||
// Server implements an App Connector as expressed in sniproxy.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // mu guards following fields
|
||||
connectors map[appctype.ConfigID]connector
|
||||
}
|
||||
|
||||
type appcMetrics struct {
|
||||
dnsResponses expvar.Int
|
||||
dnsFailures expvar.Int
|
||||
tcpConns expvar.Int
|
||||
sniConns expvar.Int
|
||||
unhandledConns expvar.Int
|
||||
}
|
||||
|
||||
var getMetrics = sync.OnceValue[*appcMetrics](func() *appcMetrics {
|
||||
m := appcMetrics{}
|
||||
|
||||
stats := new(metrics.Set)
|
||||
stats.Set("tls_sessions", &m.sniConns)
|
||||
clientmetric.NewCounterFunc("sniproxy_tls_sessions", m.sniConns.Value)
|
||||
stats.Set("tcp_sessions", &m.tcpConns)
|
||||
clientmetric.NewCounterFunc("sniproxy_tcp_sessions", m.tcpConns.Value)
|
||||
stats.Set("dns_responses", &m.dnsResponses)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_responses", m.dnsResponses.Value)
|
||||
stats.Set("dns_failed", &m.dnsFailures)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_failed", m.dnsFailures.Value)
|
||||
expvar.Publish("sniproxy", stats)
|
||||
|
||||
return &m
|
||||
})
|
||||
|
||||
// Configure applies the provided configuration to the app connector.
|
||||
func (s *Server) Configure(cfg *appctype.AppConnectorConfig) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.connectors = makeConnectorsFromConfig(cfg)
|
||||
log.Printf("installed app connector config: %+v", s.connectors)
|
||||
}
|
||||
|
||||
// HandleTCPFlow implements tsnet.FallbackTCPHandler.
|
||||
func (s *Server) HandleTCPFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
|
||||
m := getMetrics()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, c := range s.connectors {
|
||||
if handler, intercept := c.handleTCPFlow(src, dst, m); intercept {
|
||||
return handler, intercept
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// HandleDNS handles a DNS request to the app connector.
|
||||
func (s *Server) HandleDNS(c nettype.ConnPacketConn) {
|
||||
defer c.Close()
|
||||
c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
m := getMetrics()
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: read failed: %v\n ", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
host, _, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: bogus addrPort %q", addrPortStr)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
localAddr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: bogus local address %q", host)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
var msg dnsmessage.Message
|
||||
err = msg.Unpack(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, connector := range s.connectors {
|
||||
resp, err := connector.handleDNS(&msg, localAddr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: connector handling failed: %v\n", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
// This connector handled the DNS request
|
||||
_, err = c.Write(resp)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: write failed: %v\n", err)
|
||||
m.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
m.dnsResponses.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connector describes a logical collection of
|
||||
// services which need to be proxied.
|
||||
type connector struct {
|
||||
Handlers map[target]handler
|
||||
}
|
||||
|
||||
// handleTCPFlow implements tsnet.FallbackTCPHandler.
|
||||
func (c *connector) handleTCPFlow(src, dst netip.AddrPort, m *appcMetrics) (handler func(net.Conn), intercept bool) {
|
||||
for t, h := range c.Handlers {
|
||||
if t.Matching.Proto != 0 && t.Matching.Proto != int(ipproto.TCP) {
|
||||
continue
|
||||
}
|
||||
if !t.Dest.Contains(dst.Addr()) {
|
||||
continue
|
||||
}
|
||||
if !t.Matching.Ports.Contains(dst.Port()) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.(type) {
|
||||
case *tcpSNIHandler:
|
||||
m.sniConns.Add(1)
|
||||
case *tcpRoundRobinHandler:
|
||||
m.tcpConns.Add(1)
|
||||
default:
|
||||
log.Printf("handleTCPFlow: unhandled handler type %T", h)
|
||||
}
|
||||
|
||||
return h.Handle, true
|
||||
}
|
||||
|
||||
m.unhandledConns.Add(1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// handleDNS returns the DNS response to the given query. If this
|
||||
// connector is unable to handle the request, nil is returned.
|
||||
func (c *connector) handleDNS(req *dnsmessage.Message, localAddr netip.Addr) (response []byte, err error) {
|
||||
for t, h := range c.Handlers {
|
||||
if t.Dest.Contains(localAddr) {
|
||||
return makeDNSResponse(req, h.ReachableOn())
|
||||
}
|
||||
}
|
||||
|
||||
// Did not match, signal 'not handled' to caller
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (response []byte, err error) {
|
||||
resp := dnsmessage.NewBuilder(response,
|
||||
dnsmessage.Header{
|
||||
ID: req.Header.ID,
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
})
|
||||
resp.EnableCompression()
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
response, _ = resp.Finish()
|
||||
return response, nil
|
||||
}
|
||||
q := req.Questions[0]
|
||||
err = resp.StartQuestions()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Question(q)
|
||||
|
||||
err = resp.StartAnswers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch q.Type {
|
||||
case dnsmessage.TypeAAAA:
|
||||
for _, ip := range reachableIPs {
|
||||
if ip.Is6() {
|
||||
err = resp.AAAAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AAAAResource{AAAA: ip.As16()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case dnsmessage.TypeA:
|
||||
for _, ip := range reachableIPs {
|
||||
if ip.Is4() {
|
||||
err = resp.AResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AResource{A: ip.As4()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case dnsmessage.TypeSOA:
|
||||
err = resp.SOAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600,
|
||||
Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60},
|
||||
)
|
||||
case dnsmessage.TypeNS:
|
||||
err = resp.NSResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.NSResource{NS: tsMBox},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Finish()
|
||||
}
|
||||
|
||||
type handler interface {
|
||||
// Handle handles the given socket.
|
||||
Handle(c net.Conn)
|
||||
|
||||
// ReachableOn returns the IP addresses this handler is reachable on.
|
||||
ReachableOn() []netip.Addr
|
||||
}
|
||||
|
||||
func installDNATHandler(d *appctype.DNATConfig, out *connector) {
|
||||
// These handlers don't actually do DNAT, they just
|
||||
// proxy the data over the connection.
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
h := tcpRoundRobinHandler{
|
||||
To: d.To,
|
||||
DialContext: dialer.DialContext,
|
||||
ReachableIPs: d.Addrs,
|
||||
}
|
||||
|
||||
for _, addr := range d.Addrs {
|
||||
for _, protoPort := range d.IP {
|
||||
t := target{
|
||||
Dest: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Matching: protoPort,
|
||||
}
|
||||
|
||||
mak.Set(&out.Handlers, t, handler(&h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installSNIHandler(c *appctype.SNIProxyConfig, out *connector) {
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
h := tcpSNIHandler{
|
||||
Allowlist: c.AllowedDomains,
|
||||
DialContext: dialer.DialContext,
|
||||
ReachableIPs: c.Addrs,
|
||||
}
|
||||
|
||||
for _, addr := range c.Addrs {
|
||||
for _, protoPort := range c.IP {
|
||||
t := target{
|
||||
Dest: netip.PrefixFrom(addr, addr.BitLen()),
|
||||
Matching: protoPort,
|
||||
}
|
||||
|
||||
mak.Set(&out.Handlers, t, handler(&h))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeConnectorsFromConfig(cfg *appctype.AppConnectorConfig) map[appctype.ConfigID]connector {
|
||||
var connectors map[appctype.ConfigID]connector
|
||||
|
||||
for cID, d := range cfg.DNAT {
|
||||
c := connectors[cID]
|
||||
installDNATHandler(&d, &c)
|
||||
mak.Set(&connectors, cID, c)
|
||||
}
|
||||
for cID, d := range cfg.SNIProxy {
|
||||
c := connectors[cID]
|
||||
installSNIHandler(&d, &c)
|
||||
mak.Set(&connectors, cID, c)
|
||||
}
|
||||
|
||||
return connectors
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
)
|
||||
|
||||
func TestMakeConnectorsFromConfig(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
input *appctype.AppConnectorConfig
|
||||
want map[appctype.ConfigID]connector
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&appctype.AppConnectorConfig{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"DNAT",
|
||||
&appctype.AppConnectorConfig{
|
||||
DNAT: map[appctype.ConfigID]appctype.DNATConfig{
|
||||
"swiggity_swooty": {
|
||||
Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")},
|
||||
To: []string{"example.org"},
|
||||
IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[appctype.ConfigID]connector{
|
||||
"swiggity_swooty": {
|
||||
Handlers: map[target]handler{
|
||||
{
|
||||
Dest: netip.MustParsePrefix("100.64.0.1/32"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
{
|
||||
Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpRoundRobinHandler{To: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"SNIProxy",
|
||||
&appctype.AppConnectorConfig{
|
||||
SNIProxy: map[appctype.ConfigID]appctype.SNIProxyConfig{
|
||||
"swiggity_swooty": {
|
||||
Addrs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")},
|
||||
AllowedDomains: []string{"example.org"},
|
||||
IP: []tailcfg.ProtoPortRange{{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[appctype.ConfigID]connector{
|
||||
"swiggity_swooty": {
|
||||
Handlers: map[target]handler{
|
||||
{
|
||||
Dest: netip.MustParsePrefix("100.64.0.1/32"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
{
|
||||
Dest: netip.MustParsePrefix("fd7a:115c:a1e0::1/128"),
|
||||
Matching: tailcfg.ProtoPortRange{Proto: 0, Ports: tailcfg.PortRange{First: 0, Last: 65535}},
|
||||
}: &tcpSNIHandler{Allowlist: []string{"example.org"}, ReachableIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1"), netip.MustParseAddr("fd7a:115c:a1e0::1")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
connectors := makeConnectorsFromConfig(tc.input)
|
||||
|
||||
if diff := cmp.Diff(connectors, tc.want,
|
||||
cmpopts.IgnoreFields(tcpRoundRobinHandler{}, "DialContext"),
|
||||
cmpopts.IgnoreFields(tcpSNIHandler{}, "DialContext"),
|
||||
cmp.Comparer(func(x, y netip.Addr) bool {
|
||||
return x == y
|
||||
})); diff != "" {
|
||||
t.Fatalf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,31 +10,31 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"inet.af/tcpproxy"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/nettype"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/clientmetric"
|
||||
)
|
||||
|
||||
const configCapKey = "tailscale.com/sniproxy"
|
||||
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
|
||||
|
||||
// portForward is the state for a single port forwarding entry, as passed to the --forward flag.
|
||||
type portForward struct {
|
||||
@@ -68,7 +68,6 @@ func parseForward(value string) (*portForward, error) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse flags
|
||||
fs := flag.NewFlagSet("sniproxy", flag.ContinueOnError)
|
||||
var (
|
||||
ports = fs.String("ports", "443", "comma-separated list of ports to proxy")
|
||||
@@ -76,216 +75,334 @@ func main() {
|
||||
wgPort = fs.Int("wg-listen-port", 0, "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
|
||||
promoteHTTPS = fs.Bool("promote-https", true, "promote HTTP to HTTPS")
|
||||
debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint")
|
||||
hostname = fs.String("hostname", "", "Hostname to register the service under")
|
||||
)
|
||||
|
||||
err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_APPC"))
|
||||
if err != nil {
|
||||
log.Fatal("ff.Parse")
|
||||
}
|
||||
if *ports == "" {
|
||||
log.Fatal("no ports")
|
||||
}
|
||||
|
||||
var ts tsnet.Server
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
run(ctx, &ts, *wgPort, *hostname, *promoteHTTPS, *debugPort, *ports, *forwards)
|
||||
}
|
||||
|
||||
// run actually runs the sniproxy. Its separate from main() to assist in testing.
|
||||
func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, promoteHTTPS bool, debugPort int, ports, forwards string) {
|
||||
// Wire up Tailscale node + app connector server
|
||||
hostinfo.SetApp("sniproxy")
|
||||
var s sniproxy
|
||||
s.ts = ts
|
||||
|
||||
s.ts.Port = uint16(wgPort)
|
||||
s.ts.Hostname = hostname
|
||||
var s server
|
||||
s.ts.Port = uint16(*wgPort)
|
||||
defer s.ts.Close()
|
||||
|
||||
lc, err := s.ts.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("LocalClient() failed: %v", err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
s.lc = lc
|
||||
s.ts.RegisterFallbackTCPHandler(s.srv.HandleTCPFlow)
|
||||
s.initMetrics()
|
||||
|
||||
// Start special-purpose listeners: dns, http promotion, debug server
|
||||
ln, err := s.ts.Listen("udp", ":53")
|
||||
if err != nil {
|
||||
log.Fatalf("failed listening on port 53: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go s.serveDNS(ln)
|
||||
if promoteHTTPS {
|
||||
ln, err := s.ts.Listen("tcp", ":80")
|
||||
for _, portStr := range strings.Split(*ports, ",") {
|
||||
ln, err := s.ts.Listen("tcp", ":"+portStr)
|
||||
if err != nil {
|
||||
log.Fatalf("failed listening on port 80: %v", err)
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
log.Printf("Promoting HTTP to HTTPS ...")
|
||||
go s.promoteHTTPS(ln)
|
||||
}
|
||||
if debugPort != 0 {
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
dln, err := s.ts.Listen("tcp", fmt.Sprintf(":%d", debugPort))
|
||||
if err != nil {
|
||||
log.Fatalf("failed listening on debug port: %v", err)
|
||||
}
|
||||
defer dln.Close()
|
||||
go func() {
|
||||
log.Fatalf("debug serve: %v", http.Serve(dln, mux))
|
||||
}()
|
||||
log.Printf("Serving on port %v ...", portStr)
|
||||
go s.serve(ln)
|
||||
}
|
||||
|
||||
// Finally, start mainloop to configure app connector based on information
|
||||
// in the netmap.
|
||||
// We set the NotifyInitialNetMap flag so we will always get woken with the
|
||||
// current netmap, before only being woken on changes.
|
||||
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap|ipn.NotifyNoPrivateKeys)
|
||||
if err != nil {
|
||||
log.Fatalf("watching IPN bus: %v", err)
|
||||
}
|
||||
defer bus.Close()
|
||||
for {
|
||||
msg, err := bus.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
log.Fatalf("reading IPN bus: %v", err)
|
||||
}
|
||||
|
||||
// NetMap contains app-connector configuration
|
||||
if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() {
|
||||
sn := nm.SelfNode.AsStruct()
|
||||
|
||||
var c appctype.AppConnectorConfig
|
||||
nmConf, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorConfig](sn.CapMap, configCapKey)
|
||||
if err != nil {
|
||||
log.Printf("failed to read app connector configuration from coordination server: %v", err)
|
||||
} else if len(nmConf) > 0 {
|
||||
c = nmConf[0]
|
||||
}
|
||||
|
||||
if c.AdvertiseRoutes {
|
||||
if err := s.advertiseRoutesFromConfig(ctx, &c); err != nil {
|
||||
log.Printf("failed to advertise routes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Backwards compatibility: combine any configuration from control with flags specified
|
||||
// on the command line. This is intentionally done after we advertise any routes
|
||||
// because its never correct to advertise the nodes native IP addresses.
|
||||
s.mergeConfigFromFlags(&c, ports, forwards)
|
||||
s.srv.Configure(&c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sniproxy struct {
|
||||
srv Server
|
||||
ts *tsnet.Server
|
||||
lc *tailscale.LocalClient
|
||||
}
|
||||
|
||||
func (s *sniproxy) advertiseRoutesFromConfig(ctx context.Context, c *appctype.AppConnectorConfig) error {
|
||||
// Collect the set of addresses to advertise, using a map
|
||||
// to avoid duplicate entries.
|
||||
addrs := map[netip.Addr]struct{}{}
|
||||
for _, c := range c.SNIProxy {
|
||||
for _, ip := range c.Addrs {
|
||||
addrs[ip] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, c := range c.DNAT {
|
||||
for _, ip := range c.Addrs {
|
||||
addrs[ip] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var routes []netip.Prefix
|
||||
for a := range addrs {
|
||||
routes = append(routes, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
sort.SliceStable(routes, func(i, j int) bool {
|
||||
return routes[i].Addr().Less(routes[j].Addr()) // determinism r us
|
||||
})
|
||||
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
AdvertiseRoutes: routes,
|
||||
},
|
||||
AdvertiseRoutesSet: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sniproxy) mergeConfigFromFlags(out *appctype.AppConnectorConfig, ports, forwards string) {
|
||||
ip4, ip6 := s.ts.TailscaleIPs()
|
||||
|
||||
sniConfigFromFlags := appctype.SNIProxyConfig{
|
||||
Addrs: []netip.Addr{ip4, ip6},
|
||||
}
|
||||
if ports != "" {
|
||||
for _, portStr := range strings.Split(ports, ",") {
|
||||
port, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid port: %s", portStr)
|
||||
}
|
||||
sniConfigFromFlags.IP = append(sniConfigFromFlags.IP, tailcfg.ProtoPortRange{
|
||||
Proto: int(ipproto.TCP),
|
||||
Ports: tailcfg.PortRange{First: uint16(port), Last: uint16(port)},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var forwardConfigFromFlags []appctype.DNATConfig
|
||||
for _, forwStr := range strings.Split(forwards, ",") {
|
||||
for _, forwStr := range strings.Split(*forwards, ",") {
|
||||
if forwStr == "" {
|
||||
continue
|
||||
}
|
||||
forw, err := parseForward(forwStr)
|
||||
if err != nil {
|
||||
log.Printf("invalid forwarding spec: %v", err)
|
||||
continue
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
forwardConfigFromFlags = append(forwardConfigFromFlags, appctype.DNATConfig{
|
||||
Addrs: []netip.Addr{ip4, ip6},
|
||||
To: []string{forw.Destination},
|
||||
IP: []tailcfg.ProtoPortRange{
|
||||
{
|
||||
Proto: int(ipproto.TCP),
|
||||
Ports: tailcfg.PortRange{First: uint16(forw.Port), Last: uint16(forw.Port)},
|
||||
},
|
||||
},
|
||||
ln, err := s.ts.Listen("tcp", ":"+strconv.Itoa(forw.Port))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Serving on port %d to %s...", forw.Port, forw.Destination)
|
||||
|
||||
// Add an entry to the expvar LabelMap for Prometheus metrics,
|
||||
// and create a clientmetric to report that same value.
|
||||
service := portNumberToName(forw)
|
||||
s.numTCPsessions.SetInt64(service, 0)
|
||||
metric := fmt.Sprintf("sniproxy_tcp_sessions_%s", service)
|
||||
clientmetric.NewCounterFunc(metric, func() int64 {
|
||||
return s.numTCPsessions.Get(service).Value()
|
||||
})
|
||||
|
||||
go s.forward(ln, forw)
|
||||
}
|
||||
|
||||
if len(forwardConfigFromFlags) == 0 && len(sniConfigFromFlags.IP) == 0 {
|
||||
return // no config specified on the command line
|
||||
ln, err := s.ts.Listen("udp", ":53")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go s.serveDNS(ln)
|
||||
|
||||
if *promoteHTTPS {
|
||||
ln, err := s.ts.Listen("tcp", ":80")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Promoting HTTP to HTTPS ...")
|
||||
go s.promoteHTTPS(ln)
|
||||
}
|
||||
|
||||
mak.Set(&out.SNIProxy, "flags", sniConfigFromFlags)
|
||||
for i, forward := range forwardConfigFromFlags {
|
||||
mak.Set(&out.DNAT, appctype.ConfigID(fmt.Sprintf("flags_%d", i)), forward)
|
||||
if *debugPort != 0 {
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
dln, err := s.ts.Listen("tcp", fmt.Sprintf(":%d", *debugPort))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
log.Fatal(http.Serve(dln, mux))
|
||||
}()
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
func (s *sniproxy) serveDNS(ln net.Listener) {
|
||||
type server struct {
|
||||
ts tsnet.Server
|
||||
lc *tailscale.LocalClient
|
||||
|
||||
numTLSsessions expvar.Int
|
||||
numTCPsessions *metrics.LabelMap
|
||||
numBadAddrPort expvar.Int
|
||||
dnsResponses expvar.Int
|
||||
dnsFailures expvar.Int
|
||||
httpPromoted expvar.Int
|
||||
}
|
||||
|
||||
func (s *server) serve(ln net.Listener) {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("serveDNS accept: %v", err)
|
||||
return
|
||||
log.Fatal(err)
|
||||
}
|
||||
go s.srv.HandleDNS(c.(nettype.ConnPacketConn))
|
||||
go s.serveConn(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sniproxy) promoteHTTPS(ln net.Listener) {
|
||||
func (s *server) forward(ln net.Listener, forw *portForward) {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go s.forwardConn(c, forw)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) serveDNS(ln net.Listener) {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go s.serveDNSConn(c.(nettype.ConnPacketConn))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) serveDNSConn(c nettype.ConnPacketConn) {
|
||||
defer c.Close()
|
||||
c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
buf := make([]byte, 1500)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("c.Read failed: %v\n ", err)
|
||||
s.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
var msg dnsmessage.Message
|
||||
err = msg.Unpack(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("dnsmessage unpack failed: %v\n ", err)
|
||||
s.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err = s.dnsResponse(&msg)
|
||||
if err != nil {
|
||||
log.Printf("s.dnsResponse failed: %v\n", err)
|
||||
s.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.Write(buf)
|
||||
if err != nil {
|
||||
log.Printf("c.Write failed: %v\n", err)
|
||||
s.dnsFailures.Add(1)
|
||||
return
|
||||
}
|
||||
|
||||
s.dnsResponses.Add(1)
|
||||
}
|
||||
|
||||
func (s *server) serveConn(c net.Conn) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
_, port, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("bogus addrPort %q", addrPortStr)
|
||||
s.numBadAddrPort.Add(1)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 5 * time.Second
|
||||
|
||||
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) {
|
||||
s.numTLSsessions.Add(1)
|
||||
return &tcpproxy.DialProxy{
|
||||
Addr: net.JoinHostPort(sniName, port),
|
||||
DialContext: dialer.DialContext,
|
||||
}, true
|
||||
})
|
||||
p.Start()
|
||||
}
|
||||
|
||||
// portNumberToName returns a human-readable name for several port numbers commonly forwarded,
|
||||
// and "tcp###" for everything else. It is used for metric label names.
|
||||
func portNumberToName(forw *portForward) string {
|
||||
switch forw.Port {
|
||||
case 22:
|
||||
return "ssh"
|
||||
case 1433:
|
||||
return "sqlserver"
|
||||
case 3306:
|
||||
return "mysql"
|
||||
case 3389:
|
||||
return "rdp"
|
||||
case 5432:
|
||||
return "postgres"
|
||||
default:
|
||||
return fmt.Sprintf("%s%d", forw.Proto, forw.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardConn sets up a forwarder for a TCP connection. It does not inspect of the data
|
||||
// like the SNI forwarding does, it merely forwards all data to the destination specified
|
||||
// in the --forward=tcp/22/github.com argument.
|
||||
func (s *server) forwardConn(c net.Conn, forw *portForward) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
|
||||
var dialer net.Dialer
|
||||
dialer.Timeout = 30 * time.Second
|
||||
|
||||
var p tcpproxy.Proxy
|
||||
p.ListenFunc = func(net, laddr string) (net.Listener, error) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
}
|
||||
|
||||
dial := &tcpproxy.DialProxy{
|
||||
Addr: fmt.Sprintf("%s:%d", forw.Destination, forw.Port),
|
||||
DialContext: dialer.DialContext,
|
||||
}
|
||||
|
||||
p.AddRoute(addrPortStr, dial)
|
||||
s.numTCPsessions.Add(portNumberToName(forw), 1)
|
||||
p.Start()
|
||||
}
|
||||
|
||||
func (s *server) dnsResponse(req *dnsmessage.Message) (buf []byte, err error) {
|
||||
resp := dnsmessage.NewBuilder(buf,
|
||||
dnsmessage.Header{
|
||||
ID: req.Header.ID,
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
})
|
||||
resp.EnableCompression()
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
buf, _ = resp.Finish()
|
||||
return
|
||||
}
|
||||
|
||||
q := req.Questions[0]
|
||||
err = resp.StartQuestions()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Question(q)
|
||||
|
||||
ip4, ip6 := s.ts.TailscaleIPs()
|
||||
err = resp.StartAnswers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch q.Type {
|
||||
case dnsmessage.TypeAAAA:
|
||||
err = resp.AAAAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AAAAResource{AAAA: ip6.As16()},
|
||||
)
|
||||
|
||||
case dnsmessage.TypeA:
|
||||
err = resp.AResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.AResource{A: ip4.As4()},
|
||||
)
|
||||
case dnsmessage.TypeSOA:
|
||||
err = resp.SOAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600,
|
||||
Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60},
|
||||
)
|
||||
case dnsmessage.TypeNS:
|
||||
err = resp.NSResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.NSResource{NS: tsMBox},
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return resp.Finish()
|
||||
}
|
||||
|
||||
func (s *server) promoteHTTPS(ln net.Listener) {
|
||||
err := http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.httpPromoted.Add(1)
|
||||
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusFound)
|
||||
}))
|
||||
log.Fatalf("promoteHTTPS http.Serve: %v", err)
|
||||
}
|
||||
|
||||
// initMetrics sets up local prometheus metrics, and creates clientmetrics to report those
|
||||
// same counters.
|
||||
func (s *server) initMetrics() {
|
||||
stats := new(metrics.Set)
|
||||
|
||||
stats.Set("tls_sessions", &s.numTLSsessions)
|
||||
clientmetric.NewCounterFunc("sniproxy_tls_sessions", s.numTLSsessions.Value)
|
||||
|
||||
s.numTCPsessions = &metrics.LabelMap{Label: "proto"}
|
||||
stats.Set("tcp_sessions", s.numTCPsessions)
|
||||
// clientmetric doesn't have a good way to implement a Map type.
|
||||
// We create clientmetrics dynamically when parsing the --forwards argument
|
||||
|
||||
stats.Set("bad_addrport", &s.numBadAddrPort)
|
||||
clientmetric.NewCounterFunc("sniproxy_bad_addrport", s.numBadAddrPort.Value)
|
||||
|
||||
stats.Set("dns_responses", &s.dnsResponses)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_responses", s.dnsResponses.Value)
|
||||
|
||||
stats.Set("dns_failed", &s.dnsFailures)
|
||||
clientmetric.NewCounterFunc("sniproxy_dns_failed", s.dnsFailures.Value)
|
||||
|
||||
stats.Set("http_promoted", &s.httpPromoted)
|
||||
clientmetric.NewCounterFunc("sniproxy_http_promoted", s.httpPromoted.Value)
|
||||
|
||||
expvar.Publish("sniproxy", stats)
|
||||
}
|
||||
|
||||
@@ -4,30 +4,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest/integration"
|
||||
"tailscale.com/tstest/integration/testcontrol"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func TestPortForwardingArguments(t *testing.T) {
|
||||
@@ -55,169 +35,3 @@ func TestPortForwardingArguments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var verboseDERP = flag.Bool("verbose-derp", false, "if set, print DERP and STUN logs")
|
||||
var verboseNodes = flag.Bool("verbose-nodes", false, "if set, print tsnet.Server logs")
|
||||
|
||||
func startControl(t *testing.T) (control *testcontrol.Server, controlURL string) {
|
||||
// Corp#4520: don't use netns for tests.
|
||||
netns.SetEnabled(false)
|
||||
t.Cleanup(func() {
|
||||
netns.SetEnabled(true)
|
||||
})
|
||||
|
||||
derpLogf := logger.Discard
|
||||
if *verboseDERP {
|
||||
derpLogf = t.Logf
|
||||
}
|
||||
derpMap := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1")
|
||||
control = &testcontrol.Server{
|
||||
DERPMap: derpMap,
|
||||
DNSConfig: &tailcfg.DNSConfig{
|
||||
Proxied: true,
|
||||
},
|
||||
MagicDNSDomain: "tail-scale.ts.net",
|
||||
}
|
||||
control.HTTPTestServer = httptest.NewUnstartedServer(control)
|
||||
control.HTTPTestServer.Start()
|
||||
t.Cleanup(control.HTTPTestServer.Close)
|
||||
controlURL = control.HTTPTestServer.URL
|
||||
t.Logf("testcontrol listening on %s", controlURL)
|
||||
return control, controlURL
|
||||
}
|
||||
|
||||
func startNode(t *testing.T, ctx context.Context, controlURL, hostname string) (*tsnet.Server, key.NodePublic, netip.Addr) {
|
||||
t.Helper()
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), hostname)
|
||||
os.MkdirAll(tmp, 0755)
|
||||
s := &tsnet.Server{
|
||||
Dir: tmp,
|
||||
ControlURL: controlURL,
|
||||
Hostname: hostname,
|
||||
Store: new(mem.Store),
|
||||
Ephemeral: true,
|
||||
}
|
||||
if !*verboseNodes {
|
||||
s.Logf = logger.Discard
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
|
||||
status, err := s.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, status.Self.PublicKey, status.TailscaleIPs[0]
|
||||
}
|
||||
|
||||
func TestSNIProxyWithNetmapConfig(t *testing.T) {
|
||||
c, controlURL := startControl(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create a listener to proxy connections to.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Start sniproxy
|
||||
sni, nodeKey, ip := startNode(t, ctx, controlURL, "snitest")
|
||||
go run(ctx, sni, 0, sni.Hostname, false, 0, "", "")
|
||||
|
||||
// Configure the mock coordination server to send down app connector config.
|
||||
config := &appctype.AppConnectorConfig{
|
||||
DNAT: map[appctype.ConfigID]appctype.DNATConfig{
|
||||
"nic_test": {
|
||||
Addrs: []netip.Addr{ip},
|
||||
To: []string{"127.0.0.1"},
|
||||
IP: []tailcfg.ProtoPortRange{
|
||||
{
|
||||
Proto: int(ipproto.TCP),
|
||||
Ports: tailcfg.PortRange{First: uint16(ln.Addr().(*net.TCPAddr).Port), Last: uint16(ln.Addr().(*net.TCPAddr).Port)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.SetNodeCapMap(nodeKey, tailcfg.NodeCapMap{
|
||||
configCapKey: []tailcfg.RawMessage{tailcfg.RawMessage(b)},
|
||||
})
|
||||
|
||||
// Lets spin up a second node (to represent the client).
|
||||
client, _, _ := startNode(t, ctx, controlURL, "client")
|
||||
|
||||
// Make sure that the sni node has received its config.
|
||||
l, err := sni.LocalClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotConfigured := false
|
||||
for i := 0; i < 100; i++ {
|
||||
s, err := l.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Self.CapMap) > 0 {
|
||||
gotConfigured = true
|
||||
break // we got it
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !gotConfigured {
|
||||
t.Error("sni node never received its configuration from the coordination server!")
|
||||
}
|
||||
|
||||
// Lets make the client open a connection to the sniproxy node, and
|
||||
// make sure it results in a connection to our test listener.
|
||||
w, err := client.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", ip, ln.Addr().(*net.TCPAddr).Port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
r, err := ln.Accept()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
|
||||
func TestSNIProxyWithFlagConfig(t *testing.T) {
|
||||
_, controlURL := startControl(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create a listener to proxy connections to.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Start sniproxy
|
||||
sni, _, ip := startNode(t, ctx, controlURL, "snitest")
|
||||
go run(ctx, sni, 0, sni.Hostname, false, 0, "", fmt.Sprintf("tcp/%d/localhost", ln.Addr().(*net.TCPAddr).Port))
|
||||
|
||||
// Lets spin up a second node (to represent the client).
|
||||
client, _, _ := startNode(t, ctx, controlURL, "client")
|
||||
|
||||
// Lets make the client open a connection to the sniproxy node, and
|
||||
// make sure it results in a connection to our test listener.
|
||||
w, err := client.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", ip, ln.Addr().(*net.TCPAddr).Port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
r, err := ln.Accept()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Close()
|
||||
}
|
||||
|
||||
@@ -810,9 +810,6 @@ func TestPrefFlagMapping(t *testing.T) {
|
||||
case "Egg":
|
||||
// Not applicable.
|
||||
continue
|
||||
case "RunWebClient":
|
||||
// TODO(tailscale/corp#14335): Currently behind a feature flag.
|
||||
continue
|
||||
}
|
||||
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
|
||||
}
|
||||
@@ -893,7 +890,6 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
AdvertiseRoutesSet: true,
|
||||
AdvertiseTagsSet: true,
|
||||
AllowSingleHostsSet: true,
|
||||
AppConnectorSet: true,
|
||||
ControlURLSet: true,
|
||||
CorpDNSSet: true,
|
||||
ExitNodeAllowLANAccessSet: true,
|
||||
@@ -905,7 +901,6 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
OperatorUserSet: true,
|
||||
RouteAllSet: true,
|
||||
RunSSHSet: true,
|
||||
RunWebClientSet: true,
|
||||
ShieldsUpSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
@@ -1133,49 +1128,6 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
wantJustEditMP: nil,
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
},
|
||||
{
|
||||
name: "advertise_connector",
|
||||
flags: []string{"--advertise-connector"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
AppConnectorSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if !newPrefs.AppConnector.Advertise {
|
||||
t.Errorf("prefs.AppConnector.Advertise not set")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no_advertise_connector",
|
||||
flags: []string{"--advertise-connector=false"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AppConnector: ipn.AppConnectorPrefs{
|
||||
Advertise: true,
|
||||
},
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
AppConnectorSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if newPrefs.AppConnector.Advertise {
|
||||
t.Errorf("prefs.AppConnector.Advertise not unset")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -139,22 +139,11 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: localAPIAction("break-derp-conns"),
|
||||
ShortHelp: "break any open DERP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "pick-new-derp",
|
||||
Exec: localAPIAction("pick-new-derp"),
|
||||
ShortHelp: "switch to some other random DERP home region for a short time",
|
||||
},
|
||||
{
|
||||
Name: "force-netmap-update",
|
||||
Exec: localAPIAction("force-netmap-update"),
|
||||
ShortHelp: "force a full no-op netmap update (for load testing)",
|
||||
},
|
||||
{
|
||||
// TODO(bradfitz,maisem): eventually promote this out of debug
|
||||
Name: "reload-config",
|
||||
Exec: reloadConfig,
|
||||
ShortHelp: "reload config",
|
||||
},
|
||||
{
|
||||
Name: "control-knobs",
|
||||
Exec: debugControlKnobs,
|
||||
@@ -457,20 +446,6 @@ func localAPIAction(action string) func(context.Context, []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func reloadConfig(ctx context.Context, args []string) error {
|
||||
ok, err := localClient.ReloadConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
printf("config reloaded\n")
|
||||
return nil
|
||||
}
|
||||
printf("config mode not in use\n")
|
||||
os.Exit(1)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func runEnv(ctx context.Context, args []string) error {
|
||||
for _, e := range os.Environ() {
|
||||
outln(e)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -28,11 +29,8 @@ import (
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
tsrate "tailscale.com/tstime/rate"
|
||||
"tailscale.com/util/quarantine"
|
||||
"tailscale.com/util/truncate"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -54,12 +52,12 @@ var fileCmd = &ffcli.Command{
|
||||
|
||||
type countingReader struct {
|
||||
io.Reader
|
||||
n atomic.Int64
|
||||
n atomic.Uint64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(buf []byte) (int, error) {
|
||||
n, err := c.Reader.Read(buf)
|
||||
c.n.Add(int64(n))
|
||||
c.n.Add(uint64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -172,100 +170,75 @@ func runCp(ctx context.Context, args []string) error {
|
||||
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
|
||||
}
|
||||
|
||||
var group syncs.WaitGroup
|
||||
ctxProgress, cancelProgress := context.WithCancel(ctx)
|
||||
defer cancelProgress()
|
||||
var (
|
||||
done = make(chan struct{}, 1)
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
if isatty.IsTerminal(os.Stderr.Fd()) {
|
||||
group.Go(func() { progressPrinter(ctxProgress, name, fileContents.n.Load, contentLength) })
|
||||
go printProgress(&wg, done, fileContents, name, contentLength)
|
||||
wg.Add(1)
|
||||
}
|
||||
|
||||
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
|
||||
cancelProgress()
|
||||
group.Wait() // wait for progress printer to stop before reporting the error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cpArgs.verbose {
|
||||
log.Printf("sent %q", name)
|
||||
}
|
||||
done <- struct{}{}
|
||||
wg.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64) {
|
||||
var rateValueFast, rateValueSlow tsrate.Value
|
||||
rateValueFast.HalfLife = 1 * time.Second // fast response for rate measurement
|
||||
rateValueSlow.HalfLife = 10 * time.Second // slow response for ETA measurement
|
||||
var prevContentCount int64
|
||||
print := func() {
|
||||
currContentCount := contentCount()
|
||||
rateValueFast.Add(float64(currContentCount - prevContentCount))
|
||||
rateValueSlow.Add(float64(currContentCount - prevContentCount))
|
||||
prevContentCount = currContentCount
|
||||
const vtRestartLine = "\r\x1b[K"
|
||||
|
||||
const vtRestartLine = "\r\x1b[K"
|
||||
fmt.Fprintf(os.Stderr, "%s%s %s %s",
|
||||
vtRestartLine,
|
||||
rightPad(name, 36),
|
||||
leftPad(formatIEC(float64(currContentCount), "B"), len("1023.00MiB")),
|
||||
leftPad(formatIEC(rateValueFast.Rate(), "B/s"), len("1023.00MiB/s")))
|
||||
if contentLength >= 0 {
|
||||
currContentCount = min(currContentCount, contentLength) // cap at 100%
|
||||
ratioRemain := float64(currContentCount) / float64(contentLength)
|
||||
bytesRemain := float64(contentLength - currContentCount)
|
||||
secsRemain := bytesRemain / rateValueSlow.Rate()
|
||||
secs := int(min(max(0, secsRemain), 99*60*60+59+60+59))
|
||||
fmt.Fprintf(os.Stderr, " %s %s",
|
||||
leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")),
|
||||
fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60))
|
||||
}
|
||||
}
|
||||
func printProgress(wg *sync.WaitGroup, done <-chan struct{}, r *countingReader, name string, contentLength int64) {
|
||||
defer wg.Done()
|
||||
var lastBytesRead uint64
|
||||
|
||||
tc := time.NewTicker(250 * time.Millisecond)
|
||||
defer tc.Stop()
|
||||
print()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
print()
|
||||
case <-done:
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return
|
||||
case <-tc.C:
|
||||
print()
|
||||
case <-time.After(time.Second):
|
||||
n := r.n.Load()
|
||||
contentLengthStr := "???"
|
||||
if contentLength > 0 {
|
||||
contentLengthStr = fmt.Sprint(contentLength / 1024)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s%s\t\t%s", vtRestartLine, padTruncateString(name, 36), padTruncateString(fmt.Sprintf("%d/%s kb", n/1024, contentLengthStr), 16))
|
||||
if contentLength > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\t%.02f%%", float64(n)/float64(contentLength)*100)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\t-------%%")
|
||||
}
|
||||
if lastBytesRead > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\t%d kb/s", (n-lastBytesRead)/1024)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\t-------")
|
||||
}
|
||||
lastBytesRead = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func leftPad(s string, n int) string {
|
||||
s = truncateString(s, n)
|
||||
return strings.Repeat(" ", max(n-len(s), 0)) + s
|
||||
}
|
||||
|
||||
func rightPad(s string, n int) string {
|
||||
s = truncateString(s, n)
|
||||
return s + strings.Repeat(" ", max(n-len(s), 0))
|
||||
}
|
||||
|
||||
func truncateString(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
func padTruncateString(str string, truncateAt int) string {
|
||||
if len(str) <= truncateAt {
|
||||
return str + strings.Repeat(" ", truncateAt-len(str))
|
||||
}
|
||||
return truncate.String(s, max(n-1, 0)) + "…"
|
||||
}
|
||||
|
||||
func formatIEC(n float64, unit string) string {
|
||||
switch {
|
||||
case n < 1<<10:
|
||||
return fmt.Sprintf("%0.2f%s", n/(1<<0), unit)
|
||||
case n < 1<<20:
|
||||
return fmt.Sprintf("%0.2fKi%s", n/(1<<10), unit)
|
||||
case n < 1<<30:
|
||||
return fmt.Sprintf("%0.2fMi%s", n/(1<<20), unit)
|
||||
case n < 1<<40:
|
||||
return fmt.Sprintf("%0.2fGi%s", n/(1<<30), unit)
|
||||
default:
|
||||
return fmt.Sprintf("%0.2fTi%s", n/(1<<40), unit)
|
||||
// Truncate the string, but respect unicode codepoint boundaries.
|
||||
// As of RFC3629 utf-8 codepoints can be at most 4 bytes wide.
|
||||
for i := 1; i <= 4 && i < len(str)-truncateAt; i++ {
|
||||
if utf8.ValidString(str[:truncateAt-i]) {
|
||||
return str[:truncateAt-i] + "…"
|
||||
}
|
||||
}
|
||||
return "" // Should be unreachable
|
||||
}
|
||||
|
||||
func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNodeID, isOffline bool, err error) {
|
||||
|
||||
@@ -13,17 +13,22 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
var funnelCmd = func() *ffcli.Command {
|
||||
se := &serveEnv{lc: &localClient}
|
||||
// previously used to serve legacy newFunnelCommand unless useWIPCode is true
|
||||
// change is limited to make a revert easier and full cleanup to come after the relase.
|
||||
// TODO(tylersmalley): cleanup and removal of newFunnelCommand as of 2023-10-16
|
||||
return newServeV2Command(se, funnel)
|
||||
// This flag is used to switch to an in-development
|
||||
// implementation of the tailscale funnel command.
|
||||
// See https://github.com/tailscale/tailscale/issues/7844
|
||||
if envknob.UseWIPCode() {
|
||||
return newServeDevCommand(se, funnel)
|
||||
}
|
||||
return newFunnelCommand(se)
|
||||
}
|
||||
|
||||
// newFunnelCommand returns a new "funnel" subcommand using e as its environment.
|
||||
@@ -87,6 +92,10 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
|
||||
port64, err := strconv.ParseUint(args[0], 10, 16)
|
||||
if err != nil {
|
||||
@@ -98,15 +107,11 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
// Don't block from turning off existing Funnel if
|
||||
// network configuration/capabilities have changed.
|
||||
// Only block from starting new Funnels.
|
||||
if err := e.verifyFunnelEnabled(ctx, port); err != nil {
|
||||
if err := e.verifyFunnelEnabled(ctx, st, port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
|
||||
if on == sc.AllowFunnel[hp] {
|
||||
@@ -140,7 +145,13 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
// If an error is reported, the CLI should stop execution and return the error.
|
||||
//
|
||||
// verifyFunnelEnabled may refresh the local state and modify the st input.
|
||||
func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, port uint16) error {
|
||||
func (e *serveEnv) verifyFunnelEnabled(ctx context.Context, st *ipnstate.Status, port uint16) error {
|
||||
hasFunnelAttrs := func(selfNode *ipnstate.PeerStatus) bool {
|
||||
return selfNode.HasCap(tailcfg.CapabilityHTTPS) && selfNode.HasCap(tailcfg.NodeAttrFunnel)
|
||||
}
|
||||
if hasFunnelAttrs(st.Self) {
|
||||
return nil // already enabled
|
||||
}
|
||||
enableErr := e.enableFeatureInteractive(ctx, "funnel", tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel)
|
||||
st, statusErr := e.getLocalClientStatusWithoutPeers(ctx) // get updated status; interactive flow may block
|
||||
switch {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -33,14 +34,17 @@ import (
|
||||
|
||||
var serveCmd = func() *ffcli.Command {
|
||||
se := &serveEnv{lc: &localClient}
|
||||
// previously used to serve legacy newFunnelCommand unless useWIPCode is true
|
||||
// change is limited to make a revert easier and full cleanup to come after the relase.
|
||||
// TODO(tylersmalley): cleanup and removal of newServeLegacyCommand as of 2023-10-16
|
||||
return newServeV2Command(se, serve)
|
||||
// This flag is used to switch to an in-development
|
||||
// implementation of the tailscale funnel command.
|
||||
// See https://github.com/tailscale/tailscale/issues/7844
|
||||
if envknob.UseWIPCode() {
|
||||
return newServeDevCommand(se, serve)
|
||||
}
|
||||
return newServeCommand(se)
|
||||
}
|
||||
|
||||
// newServeLegacyCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeLegacyCommand(e *serveEnv) *ffcli.Command {
|
||||
// newServeCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeCommand(e *serveEnv) *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "serve",
|
||||
ShortHelp: "Serve content and local servers",
|
||||
@@ -159,19 +163,17 @@ type serveEnv struct {
|
||||
// v2 specific flags
|
||||
bg bool // background mode
|
||||
setPath string // serve path
|
||||
https uint // HTTP port
|
||||
http uint // HTTP port
|
||||
tcp uint // TCP port
|
||||
tlsTerminatedTCP uint // a TLS terminated TCP port
|
||||
https string // HTTP port
|
||||
http string // HTTP port
|
||||
tcp string // TCP port
|
||||
tlsTerminatedTCP string // a TLS terminated TCP port
|
||||
subcmd serveMode // subcommand
|
||||
yes bool // update without prompt
|
||||
|
||||
lc localServeClient // localClient interface, specific to serve
|
||||
|
||||
// optional stuff for tests:
|
||||
testFlagOut io.Writer
|
||||
testStdout io.Writer
|
||||
testStderr io.Writer
|
||||
}
|
||||
|
||||
// getSelfDNSName returns the DNS name of the current node.
|
||||
@@ -682,6 +684,13 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) stdout() io.Writer {
|
||||
if e.testStdout != nil {
|
||||
return e.testStdout
|
||||
}
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error {
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
for p, h := range sc.TCP {
|
||||
@@ -818,24 +827,6 @@ func parseServePort(s string) (uint16, error) {
|
||||
// 2023-08-09: The only valid feature values are "serve" and "funnel".
|
||||
// This can be moved to some CLI lib when expanded past serve/funnel.
|
||||
func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string, caps ...tailcfg.NodeCapability) (err error) {
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
if st.Self == nil {
|
||||
return errors.New("no self node")
|
||||
}
|
||||
hasCaps := func() bool {
|
||||
for _, c := range caps {
|
||||
if !st.Self.HasCap(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if hasCaps() {
|
||||
return nil // already enabled
|
||||
}
|
||||
info, err := e.lc.QueryFeature(ctx, feature)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -5,20 +5,17 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -41,19 +38,15 @@ type commandInfo struct {
|
||||
}
|
||||
|
||||
var serveHelpCommon = strings.TrimSpace(`
|
||||
<target> can be a file, directory, text, or most commonly the location to a service running on the
|
||||
local machine. The location to the location service can be expressed as a port number (e.g., 3000),
|
||||
a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http://localhost:3000/foo).
|
||||
<target> can be a port number (e.g., 3000), a partial URL (e.g., localhost:3000), or a
|
||||
full URL including a path (e.g., http://localhost:3000/foo, https+insecure://localhost:3000/foo).
|
||||
|
||||
EXAMPLES
|
||||
- Expose an HTTP server running at 127.0.0.1:3000 in the foreground:
|
||||
$ tailscale %[1]s 3000
|
||||
- Mount a local web server at 127.0.0.1:3000 in the foreground:
|
||||
$ tailscale %s localhost:3000
|
||||
|
||||
- Expose an HTTP server running at 127.0.0.1:3000 in the background:
|
||||
$ tailscale %[1]s --bg 3000
|
||||
|
||||
- Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443
|
||||
$ tailscale %[1]s https+insecure://localhost:8443
|
||||
- Mount a local web server at 127.0.0.1:3000 in the background:
|
||||
$ tailscale %s --bg localhost:3000
|
||||
|
||||
For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
|
||||
`)
|
||||
@@ -79,7 +72,7 @@ var infoMap = map[serveMode]commandInfo{
|
||||
Name: "serve",
|
||||
ShortHelp: "Serve content and local servers on your tailnet",
|
||||
LongHelp: strings.Join([]string{
|
||||
"Tailscale Serve enables you to share a local server securely within your tailnet.\n",
|
||||
"Serve enables you to share a local server securely within your tailnet.\n",
|
||||
"To share a local server on the internet, use `tailscale funnel`\n\n",
|
||||
}, "\n"),
|
||||
},
|
||||
@@ -101,14 +94,8 @@ func buildShortUsage(subcmd string) string {
|
||||
}, "\n ")
|
||||
}
|
||||
|
||||
// errHelpFunc is standard error text that prompts users to
|
||||
// run `$subcmd --help` for information on how to use serve.
|
||||
var errHelpFunc = func(m serveMode) error {
|
||||
return fmt.Errorf("try `tailscale %s --help` for usage info", infoMap[m].Name)
|
||||
}
|
||||
|
||||
// newServeV2Command returns a new "serve" subcommand using e as its environment.
|
||||
func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
// newServeDevCommand returns a new "serve" subcommand using e as its environment.
|
||||
func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
if subcmd != serve && subcmd != funnel {
|
||||
log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd)
|
||||
}
|
||||
@@ -123,21 +110,19 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
fmt.Sprintf("%s status [--json]", info.Name),
|
||||
fmt.Sprintf("%s reset", info.Name),
|
||||
}, "\n "),
|
||||
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name),
|
||||
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name, info.Name),
|
||||
Exec: e.runServeCombined(subcmd),
|
||||
|
||||
FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.bg, "bg", false, "Run the command as a background process (default false)")
|
||||
fs.StringVar(&e.setPath, "set-path", "", "Appends the specified path to the base URL for accessing the underlying service")
|
||||
fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)")
|
||||
if subcmd == serve {
|
||||
fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port")
|
||||
}
|
||||
fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port")
|
||||
fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
|
||||
fs.BoolVar(&e.yes, "yes", false, "Update without interactive prompts (default false)")
|
||||
fs.BoolVar(&e.bg, "bg", false, "run the command in the background")
|
||||
fs.StringVar(&e.setPath, "set-path", "", "set a path for a specific target and run in the background")
|
||||
fs.StringVar(&e.https, "https", "", "default; HTTPS listener")
|
||||
fs.StringVar(&e.http, "http", "", "HTTP listener")
|
||||
fs.StringVar(&e.tcp, "tcp", "", "TCP listener")
|
||||
fs.StringVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", "", "TLS terminated TCP listener")
|
||||
|
||||
}),
|
||||
UsageFunc: usageFuncNoDefaultValues,
|
||||
UsageFunc: usageFunc,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
@@ -159,31 +144,20 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serveEnv) validateArgs(subcmd serveMode, args []string) error {
|
||||
if translation, ok := isLegacyInvocation(subcmd, args); ok {
|
||||
fmt.Fprint(e.stderr(), "Error: the CLI for serve and funnel has changed.")
|
||||
if translation != "" {
|
||||
fmt.Fprint(e.stderr(), " You can run the following command instead:\n")
|
||||
fmt.Fprintf(e.stderr(), "\t- %s\n", translation)
|
||||
}
|
||||
fmt.Fprint(e.stderr(), "\nPlease see https://tailscale.com/kb/1242/tailscale-serve for more information.\n")
|
||||
return errHelpFunc(subcmd)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
func validateArgs(subcmd serveMode, args []string) error {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return flag.ErrHelp
|
||||
case 1, 2:
|
||||
if isLegacyInvocation(subcmd, args) {
|
||||
fmt.Fprintf(os.Stderr, "error: the CLI for serve and funnel has changed.")
|
||||
fmt.Fprintf(os.Stderr, "Please see https://tailscale.com/kb/1242/tailscale-serve for more information.")
|
||||
return errHelp
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments (%d)", len(args))
|
||||
return errHelp
|
||||
}
|
||||
if len(args) > 2 {
|
||||
fmt.Fprintf(e.stderr(), "Error: invalid number of arguments (%d)\n", len(args))
|
||||
return errHelpFunc(subcmd)
|
||||
}
|
||||
turnOff := args[len(args)-1] == "off"
|
||||
if len(args) == 2 && !turnOff {
|
||||
fmt.Fprintln(e.stderr(), "Error: invalid argument format")
|
||||
return errHelpFunc(subcmd)
|
||||
}
|
||||
|
||||
// Given the two checks above, we can assume there
|
||||
// are only 1 or 2 arguments which is valid.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -192,31 +166,22 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
e.subcmd = subcmd
|
||||
|
||||
return func(ctx context.Context, args []string) error {
|
||||
// Undocumented debug command (not using ffcli subcommands) to set raw
|
||||
// configs from stdin for now (2022-11-13).
|
||||
if len(args) == 1 && args[0] == "set-raw" {
|
||||
valb, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := new(ipn.ServeConfig)
|
||||
if err := json.Unmarshal(valb, sc); err != nil {
|
||||
return fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return e.lc.SetServeConfig(ctx, sc)
|
||||
}
|
||||
|
||||
if err := e.validateArgs(subcmd, args); err != nil {
|
||||
if err := validateArgs(subcmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
|
||||
funnel := subcmd == funnel
|
||||
if funnel {
|
||||
// verify node has funnel capabilities
|
||||
if err := e.verifyFunnelEnabled(ctx, 443); err != nil {
|
||||
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -226,10 +191,18 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
return fmt.Errorf("failed to clean the mount point: %w", err)
|
||||
}
|
||||
|
||||
if e.setPath != "" {
|
||||
// TODO(marwan-at-work): either
|
||||
// 1. Warn the user that this is a side effect.
|
||||
// 2. Force the user to pass --bg
|
||||
// 3. Allow set-path to be in the foreground.
|
||||
e.bg = true
|
||||
}
|
||||
|
||||
srvType, srvPort, err := srvTypeAndPortFromFlags(e)
|
||||
if err != nil {
|
||||
fmt.Fprintf(e.stderr(), "error: %v\n\n", err)
|
||||
return errHelpFunc(subcmd)
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
|
||||
return errHelp
|
||||
}
|
||||
|
||||
sc, err := e.lc.GetServeConfig(ctx)
|
||||
@@ -241,10 +214,6 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
|
||||
// set parent serve config to always be persisted
|
||||
@@ -270,13 +239,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
}
|
||||
|
||||
var watcher *tailscale.IPNBusWatcher
|
||||
wantFg := !e.bg && !turnOff
|
||||
if wantFg {
|
||||
// validate the config before creating a WatchIPNBus session
|
||||
if err := e.validateConfig(parentSC, srvPort, srvType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !e.bg && !turnOff {
|
||||
// if foreground mode, create a WatchIPNBus session
|
||||
// and use the nested config for all following operations
|
||||
// TODO(marwan-at-work): nested-config validations should happen here or previous to this point.
|
||||
@@ -305,22 +268,22 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
return err
|
||||
}
|
||||
err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel)
|
||||
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
|
||||
msg = e.messageForPort(sc, st, dnsName, srvPort)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(e.stderr(), "error: %v\n\n", err)
|
||||
return errHelpFunc(subcmd)
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
|
||||
return errHelp
|
||||
}
|
||||
|
||||
if err := e.lc.SetServeConfig(ctx, parentSC); err != nil {
|
||||
if tailscale.IsPreconditionsFailedError(err) {
|
||||
fmt.Fprintln(e.stderr(), "Another client is changing the serve config; please try again.")
|
||||
fmt.Fprintln(os.Stderr, "Another client is changing the serve config; please try again.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if msg != "" {
|
||||
fmt.Fprintln(e.stdout(), msg)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
}
|
||||
|
||||
if watcher != nil {
|
||||
@@ -339,8 +302,6 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration"
|
||||
|
||||
func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error {
|
||||
sc, isFg := findConfig(sc, port)
|
||||
if sc == nil {
|
||||
@@ -350,7 +311,7 @@ func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe se
|
||||
return errors.New("foreground already exists under this port")
|
||||
}
|
||||
if !e.bg {
|
||||
return fmt.Errorf(backgroundExistsMsg, infoMap[e.subcmd].Name, wantServe.String(), port)
|
||||
return errors.New("background serve already exists under this port")
|
||||
}
|
||||
existingServe := serveFromPortHandler(sc.TCP[port])
|
||||
if wantServe != existingServe {
|
||||
@@ -402,10 +363,6 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st
|
||||
return fmt.Errorf("failed apply web serve: %w", err)
|
||||
}
|
||||
case serveTypeTCP, serveTypeTLSTerminatedTCP:
|
||||
if e.setPath != "" {
|
||||
return fmt.Errorf("cannot mount a path for TCP serve")
|
||||
}
|
||||
|
||||
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply TCP serve: %w", err)
|
||||
@@ -420,27 +377,18 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
msgFunnelAvailable = "Available on the internet:"
|
||||
msgServeAvailable = "Available within your tailnet:"
|
||||
msgRunningInBackground = "%s started and running in the background."
|
||||
msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off"
|
||||
msgToExit = "Press Ctrl+C to exit."
|
||||
)
|
||||
|
||||
// messageForPort returns a message for the given port based on the
|
||||
// serve config and status.
|
||||
func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16) string {
|
||||
func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvPort uint16) string {
|
||||
var output strings.Builder
|
||||
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
|
||||
if sc.AllowFunnel[hp] == true {
|
||||
output.WriteString(msgFunnelAvailable)
|
||||
output.WriteString("Available on the internet:\n")
|
||||
} else {
|
||||
output.WriteString(msgServeAvailable)
|
||||
output.WriteString("Available within your tailnet:\n")
|
||||
}
|
||||
output.WriteString("\n\n")
|
||||
|
||||
scheme := "https"
|
||||
if sc.IsServingHTTP(srvPort) {
|
||||
@@ -453,6 +401,13 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
||||
portPart = ""
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart))
|
||||
|
||||
if !e.bg {
|
||||
output.WriteString("Press Ctrl+C to exit.")
|
||||
return output.String()
|
||||
}
|
||||
|
||||
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
|
||||
switch {
|
||||
case h.Path != "":
|
||||
@@ -474,12 +429,12 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
||||
sort.Slice(mounts, func(i, j int) bool {
|
||||
return len(mounts[i]) < len(mounts[j])
|
||||
})
|
||||
maxLen := len(mounts[len(mounts)-1])
|
||||
|
||||
for _, m := range mounts {
|
||||
h := sc.Web[hp].Handlers[m]
|
||||
t, d := srvTypeAndDesc(h)
|
||||
output.WriteString(fmt.Sprintf("%s://%s%s%s\n", scheme, dnsName, portPart, m))
|
||||
output.WriteString(fmt.Sprintf("%s %-5s %s\n\n", "|--", t, d))
|
||||
output.WriteString(fmt.Sprintf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d))
|
||||
}
|
||||
} else if sc.TCP[srvPort] != nil {
|
||||
h := sc.TCP[srvPort]
|
||||
@@ -489,7 +444,6 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
||||
tlsStatus = "TLS terminated"
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("%s://%s%s\n", scheme, dnsName, portPart))
|
||||
output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus))
|
||||
for _, a := range st.TailscaleIPs {
|
||||
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort)))
|
||||
@@ -498,17 +452,8 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
|
||||
output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward))
|
||||
}
|
||||
|
||||
if !e.bg {
|
||||
output.WriteString(msgToExit)
|
||||
return output.String()
|
||||
}
|
||||
|
||||
subCmd := infoMap[e.subcmd].Name
|
||||
subCmdUpper := strings.ToUpper(string(subCmd[0])) + subCmd[1:]
|
||||
|
||||
output.WriteString(fmt.Sprintf(msgRunningInBackground, subCmdUpper))
|
||||
output.WriteString("\n")
|
||||
output.WriteString(fmt.Sprintf(msgDisableProxy, subCmd, srvType.String(), srvPort))
|
||||
output.WriteString("\nServe started and running in the background.\n")
|
||||
output.WriteString(fmt.Sprintf("To disable the proxy, run: tailscale %s off", infoMap[e.subcmd].Name))
|
||||
|
||||
return output.String()
|
||||
}
|
||||
@@ -543,7 +488,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
|
||||
}
|
||||
h.Path = target
|
||||
default:
|
||||
t, err := expandProxyTargetDev(target, []string{"http", "https", "https+insecure"}, "http")
|
||||
t, err := expandProxyTargetDev(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,22 +538,34 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
|
||||
return fmt.Errorf("invalid TCP target %q", target)
|
||||
}
|
||||
|
||||
targetURL, err := expandProxyTargetDev(target, []string{"tcp"}, "tcp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to expand target: %v", err)
|
||||
}
|
||||
|
||||
dstURL, err := url.Parse(targetURL)
|
||||
dstURL, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TCP target %q: %v", target, err)
|
||||
}
|
||||
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TCP target %q: %v", target, err)
|
||||
}
|
||||
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1":
|
||||
// ok
|
||||
default:
|
||||
return fmt.Errorf("invalid TCP target %q, must be one of localhost or 127.0.0.1", target)
|
||||
}
|
||||
|
||||
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
|
||||
return fmt.Errorf("invalid port %q", dstPortStr)
|
||||
}
|
||||
|
||||
fwdAddr := "127.0.0.1:" + dstPortStr
|
||||
|
||||
// TODO: needs to account for multiple configs from foreground mode
|
||||
if sc.IsServingWeb(srcPort) {
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: dstURL.Host})
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
|
||||
|
||||
if terminateTLS {
|
||||
sc.TCP[srcPort].TerminateTLS = dnsName
|
||||
@@ -630,9 +587,6 @@ func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint
|
||||
// TODO: add error handling for if toggling for existing sc
|
||||
if allowFunnel {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
} else if _, exists := sc.AllowFunnel[hp]; exists {
|
||||
fmt.Fprintf(e.stderr(), "Removing Funnel for %s\n", hp)
|
||||
delete(sc.AllowFunnel, hp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +613,7 @@ func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serve
|
||||
}
|
||||
|
||||
func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) {
|
||||
sourceMap := map[serveType]uint{
|
||||
sourceMap := map[serveType]string{
|
||||
serveTypeHTTP: e.http,
|
||||
serveTypeHTTPS: e.https,
|
||||
serveTypeTCP: e.tcp,
|
||||
@@ -667,15 +621,13 @@ func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, er
|
||||
}
|
||||
|
||||
var srcTypeCount int
|
||||
var srcValue string
|
||||
|
||||
for k, v := range sourceMap {
|
||||
if v != 0 {
|
||||
if v > math.MaxUint16 {
|
||||
return 0, 0, fmt.Errorf("port number %d is too high for %s flag", v, srvType)
|
||||
}
|
||||
if v != "" {
|
||||
srcTypeCount++
|
||||
srvType = k
|
||||
srvPort = uint16(v)
|
||||
srcValue = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,104 +635,29 @@ func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, er
|
||||
return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point")
|
||||
} else if srcTypeCount == 0 {
|
||||
srvType = serveTypeHTTPS
|
||||
srvPort = 443
|
||||
srcValue = "443"
|
||||
}
|
||||
|
||||
srvPort, err = parseServePort(srcValue)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid port %q: %w", srcValue, err)
|
||||
}
|
||||
|
||||
return srvType, srvPort, nil
|
||||
}
|
||||
|
||||
// isLegacyInvocation helps transition customers who have been using the beta
|
||||
// CLI to the newer API by returning a translation from the old command to the new command.
|
||||
// The second result is a boolean that only returns true if the given arguments is a valid
|
||||
// legacy invocation. If the given args are in the old format but are not valid, it will
|
||||
// return false and expects the new code path has enough validations to reject the request.
|
||||
func isLegacyInvocation(subcmd serveMode, args []string) (string, bool) {
|
||||
if subcmd == funnel {
|
||||
if len(args) != 2 {
|
||||
return "", false
|
||||
}
|
||||
_, err := strconv.ParseUint(args[0], 10, 16)
|
||||
return "", err == nil && (args[1] == "on" || args[1] == "off")
|
||||
}
|
||||
turnOff := len(args) > 1 && args[len(args)-1] == "off"
|
||||
if turnOff {
|
||||
args = args[:len(args)-1]
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return "", false
|
||||
}
|
||||
func isLegacyInvocation(subcmd serveMode, args []string) bool {
|
||||
if subcmd == serve && len(args) == 2 {
|
||||
prefixes := []string{"http", "https", "tcp", "tls-terminated-tcp"}
|
||||
|
||||
srcType, srcPortStr, found := strings.Cut(args[0], ":")
|
||||
if !found {
|
||||
if srcType == "https" && srcPortStr == "" {
|
||||
// Default https port to 443.
|
||||
srcPortStr = "443"
|
||||
} else if srcType == "http" && srcPortStr == "" {
|
||||
// Default http port to 80.
|
||||
srcPortStr = "80"
|
||||
} else {
|
||||
return "", false
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(args[0], prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wantLength int
|
||||
switch srcType {
|
||||
case "https", "http":
|
||||
wantLength = 3
|
||||
case "tcp", "tls-terminated-tcp":
|
||||
wantLength = 2
|
||||
default:
|
||||
// return non-legacy, and let new code handle validation.
|
||||
return "", false
|
||||
}
|
||||
// The length is either exactlly the same as in "https / <target>"
|
||||
// or target is omitted as in "https / off" where omit the off at
|
||||
// the top.
|
||||
if len(args) != wantLength && !(turnOff && len(args) == wantLength-1) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cmd := []string{"tailscale", "serve", "--bg"}
|
||||
switch srcType {
|
||||
case "https":
|
||||
// In the new code, we default to https:443,
|
||||
// so we don't need to pass the flag explicitly.
|
||||
if srcPortStr != "443" {
|
||||
cmd = append(cmd, fmt.Sprintf("--https %s", srcPortStr))
|
||||
}
|
||||
case "http":
|
||||
cmd = append(cmd, fmt.Sprintf("--http %s", srcPortStr))
|
||||
case "tcp", "tls-terminated-tcp":
|
||||
cmd = append(cmd, fmt.Sprintf("--%s %s", srcType, srcPortStr))
|
||||
}
|
||||
|
||||
var mount string
|
||||
if srcType == "https" || srcType == "http" {
|
||||
mount = args[1]
|
||||
if _, err := cleanMountPoint(mount); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if mount != "/" {
|
||||
cmd = append(cmd, "--set-path "+mount)
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no "off" there must always be a target destination.
|
||||
// If there is "off", target is optional so check if it exists
|
||||
// first before appending it.
|
||||
hasTarget := !turnOff || (turnOff && len(args) == wantLength)
|
||||
if hasTarget {
|
||||
dest := args[len(args)-1]
|
||||
if strings.Contains(dest, " ") {
|
||||
dest = strconv.Quote(dest)
|
||||
}
|
||||
cmd = append(cmd, dest)
|
||||
}
|
||||
if turnOff {
|
||||
cmd = append(cmd, "off")
|
||||
}
|
||||
|
||||
return strings.Join(cmd, " "), true
|
||||
return false
|
||||
}
|
||||
|
||||
// removeWebServe removes a web handler from the serve config
|
||||
@@ -792,43 +669,15 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u
|
||||
return errors.New("cannot remove web handler; currently serving TCP")
|
||||
}
|
||||
|
||||
portStr := strconv.Itoa(int(srvPort))
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, portStr))
|
||||
|
||||
var targetExists bool
|
||||
var mounts []string
|
||||
// mount is deduced from e.setPath but it is ambiguous as
|
||||
// to whether the user explicitly passed "/" or it was defaulted to.
|
||||
if e.setPath == "" {
|
||||
targetExists = sc.Web[hp] != nil && len(sc.Web[hp].Handlers) > 0
|
||||
if targetExists {
|
||||
for mount := range sc.Web[hp].Handlers {
|
||||
mounts = append(mounts, mount)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targetExists = sc.WebHandlerExists(hp, mount)
|
||||
mounts = []string{mount}
|
||||
}
|
||||
|
||||
if !targetExists {
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
if !sc.WebHandlerExists(hp, mount) {
|
||||
return errors.New("error: handler does not exist")
|
||||
}
|
||||
|
||||
if len(mounts) > 1 {
|
||||
msg := fmt.Sprintf("Are you sure you want to delete %d handlers under port %s?", len(mounts), portStr)
|
||||
if !e.yes && !promptYesNo(msg) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// delete existing handler, then cascade delete if empty
|
||||
for _, m := range mounts {
|
||||
delete(sc.Web[hp].Handlers, m)
|
||||
}
|
||||
delete(sc.Web[hp].Handlers, mount)
|
||||
if len(sc.Web[hp].Handlers) == 0 {
|
||||
delete(sc.Web, hp)
|
||||
delete(sc.AllowFunnel, hp)
|
||||
delete(sc.TCP, srvPort)
|
||||
}
|
||||
|
||||
@@ -846,10 +695,6 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u
|
||||
delete(sc.AllowFunnel, hp)
|
||||
}
|
||||
|
||||
if len(sc.AllowFunnel) == 0 {
|
||||
sc.AllowFunnel = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -880,22 +725,24 @@ func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
|
||||
// examples:
|
||||
// - 3000
|
||||
// - localhost:3000
|
||||
// - tcp://localhost:3000
|
||||
// - http://localhost:3000
|
||||
// - https://localhost:3000
|
||||
// - https-insecure://localhost:3000
|
||||
// - https-insecure://localhost:3000/foo
|
||||
func expandProxyTargetDev(target string, supportedSchemes []string, defaultScheme string) (string, error) {
|
||||
const host = "127.0.0.1"
|
||||
func expandProxyTargetDev(target string) (string, error) {
|
||||
var (
|
||||
scheme = "http"
|
||||
host = "127.0.0.1"
|
||||
)
|
||||
|
||||
// support target being a port number
|
||||
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
|
||||
return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, host, port), nil
|
||||
}
|
||||
|
||||
// prepend scheme if not present
|
||||
if !strings.Contains(target, "://") {
|
||||
target = defaultScheme + "://" + target
|
||||
target = scheme + "://" + target
|
||||
}
|
||||
|
||||
// make sure we can parse the target
|
||||
@@ -905,15 +752,10 @@ func expandProxyTargetDev(target string, supportedSchemes []string, defaultSchem
|
||||
}
|
||||
|
||||
// ensure a supported scheme
|
||||
if !slices.Contains(supportedSchemes, u.Scheme) {
|
||||
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
|
||||
}
|
||||
|
||||
// validate the host.
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1":
|
||||
switch u.Scheme {
|
||||
case "http", "https", "https+insecure":
|
||||
default:
|
||||
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
|
||||
return "", errors.New("must be a URL starting with http://, https://, or https+insecure://")
|
||||
}
|
||||
|
||||
// validate the port
|
||||
@@ -922,7 +764,13 @@ func expandProxyTargetDev(target string, supportedSchemes []string, defaultSchem
|
||||
return "", fmt.Errorf("invalid port %q", u.Port())
|
||||
}
|
||||
|
||||
u.Host = fmt.Sprintf("%s:%d", host, port)
|
||||
// validate the host.
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1":
|
||||
u.Host = fmt.Sprintf("%s:%d", host, port)
|
||||
default:
|
||||
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -960,17 +808,3 @@ func (s serveType) String() string {
|
||||
return "unknownServeType"
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serveEnv) stdout() io.Writer {
|
||||
if e.testStdout != nil {
|
||||
return e.testStdout
|
||||
}
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
func (e *serveEnv) stderr() io.Writer {
|
||||
if e.testStderr != nil {
|
||||
return e.testStderr
|
||||
}
|
||||
return os.Stderr
|
||||
}
|
||||
1060
cmd/tailscale/cli/serve_dev_test.go
Normal file
1060
cmd/tailscale/cli/serve_dev_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -713,7 +713,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
cmd = newFunnelCommand(e)
|
||||
args = st.command[1:]
|
||||
} else {
|
||||
cmd = newServeLegacyCommand(e)
|
||||
cmd = newServeCommand(e)
|
||||
args = st.command
|
||||
}
|
||||
err := cmd.ParseAndRun(context.Background(), args)
|
||||
@@ -786,7 +786,7 @@ func TestVerifyFunnelEnabled(t *testing.T) {
|
||||
{
|
||||
name: "fallback-flow-enabled",
|
||||
queryFeatureResponse: mockQueryFeatureResponse{resp: nil, err: errors.New("not-allowed")},
|
||||
caps: []tailcfg.NodeCapability{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel, "https://tailscale.com/cap/funnel-ports?ports=80,443,8080-8090"},
|
||||
caps: []tailcfg.NodeCapability{tailcfg.CapabilityHTTPS, tailcfg.NodeAttrFunnel},
|
||||
wantErr: "", // no error, success
|
||||
},
|
||||
{
|
||||
@@ -811,6 +811,10 @@ func TestVerifyFunnelEnabled(t *testing.T) {
|
||||
defer func() { fakeStatus.Self.Capabilities = oldCaps }() // reset after test
|
||||
fakeStatus.Self.Capabilities = tt.caps
|
||||
}
|
||||
st, err := e.getLocalClientStatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
@@ -822,7 +826,7 @@ func TestVerifyFunnelEnabled(t *testing.T) {
|
||||
t.Errorf("wrong panic; got=%s, want=%s", gotPanic, tt.wantPanic)
|
||||
}
|
||||
}()
|
||||
gotErr := e.verifyFunnelEnabled(ctx, 443)
|
||||
gotErr := e.verifyFunnelEnabled(ctx, st, 443)
|
||||
var got string
|
||||
if gotErr != nil {
|
||||
got = gotErr.Error()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/clientupdate"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
var setCmd = &ffcli.Command{
|
||||
@@ -42,18 +40,15 @@ type setArgsT struct {
|
||||
exitNodeAllowLANAccess bool
|
||||
shieldsUp bool
|
||||
runSSH bool
|
||||
runWebClient bool
|
||||
hostname string
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
advertiseConnector bool
|
||||
opUser string
|
||||
acceptedRisks string
|
||||
profileName string
|
||||
forceDaemon bool
|
||||
updateCheck bool
|
||||
updateApply bool
|
||||
postureChecking bool
|
||||
}
|
||||
|
||||
func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
@@ -69,16 +64,8 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||
setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
||||
setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates")
|
||||
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
|
||||
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information")
|
||||
|
||||
// TODO(tailscale/corp#14335): during development only expose -webclient on dev and unstable builds
|
||||
if version.GetMeta().IsDev || version.IsUnstableBuild() {
|
||||
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "run a web client, permitting access per tailnet admin's declared policy")
|
||||
}
|
||||
|
||||
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "HIDDEN: notify about available Tailscale updates")
|
||||
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "HIDDEN: automatically update to the latest available version")
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
}
|
||||
@@ -114,7 +101,6 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess,
|
||||
ShieldsUp: setArgs.shieldsUp,
|
||||
RunSSH: setArgs.runSSH,
|
||||
RunWebClient: setArgs.runWebClient,
|
||||
Hostname: setArgs.hostname,
|
||||
OperatorUser: setArgs.opUser,
|
||||
ForceDaemon: setArgs.forceDaemon,
|
||||
@@ -122,10 +108,6 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
Check: setArgs.updateCheck,
|
||||
Apply: setArgs.updateApply,
|
||||
},
|
||||
AppConnector: ipn.AppConnectorPrefs{
|
||||
Advertise: setArgs.advertiseConnector,
|
||||
},
|
||||
PostureChecking: setArgs.postureChecking,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,22 +153,9 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
}
|
||||
}
|
||||
if maskedPrefs.AutoUpdateSet {
|
||||
// On macsys, tailscaled will set the Sparkle auto-update setting. It
|
||||
// does not use clientupdate.
|
||||
if version.IsMacSysExt() {
|
||||
apply := "0"
|
||||
if maskedPrefs.AutoUpdate.Apply {
|
||||
apply = "1"
|
||||
}
|
||||
out, err := exec.Command("defaults", "write", "io.tailscale.ipn.macsys", "SUAutomaticallyUpdate", apply).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out)
|
||||
}
|
||||
} else {
|
||||
_, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true})
|
||||
if errors.Is(err, errors.ErrUnsupported) {
|
||||
return errors.New("automatic updates are not supported on this platform")
|
||||
}
|
||||
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
|
||||
if errors.Is(err, errors.ErrUnsupported) {
|
||||
return errors.New("automatic updates are not supported on this platform")
|
||||
}
|
||||
}
|
||||
checkPrefs := curPrefs.Clone()
|
||||
|
||||
@@ -238,11 +238,7 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
}
|
||||
printFunnelStatus(ctx)
|
||||
if cv := st.ClientVersion; cv != nil && !cv.RunningLatest && cv.LatestVersion != "" {
|
||||
if cv.UrgentSecurityUpdate {
|
||||
printf("# Security update available: %v -> %v, run `tailscale update` or `tailscale set --auto-update` to update.\n", version.Short(), cv.LatestVersion)
|
||||
} else {
|
||||
printf("# Update available: %v -> %v, run `tailscale update` or `tailscale set --auto-update` to update.\n", version.Short(), cv.LatestVersion)
|
||||
}
|
||||
printf("# Update available: %v -> %v, run `tailscale update` or `tailscale set --auto-update` to update.\n", version.Short(), cv.LatestVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,14 +113,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
|
||||
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
|
||||
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
||||
upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector")
|
||||
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
|
||||
// TODO(tailscale/corp#14335): during development only expose -webclient on dev and unstable builds
|
||||
if version.GetMeta().IsDev || version.IsUnstableBuild() {
|
||||
upf.BoolVar(&upArgs.runWebClient, "webclient", false, "run a web client, permitting access per tailnet admin's declared policy")
|
||||
}
|
||||
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
}
|
||||
@@ -166,13 +159,11 @@ type upArgsT struct {
|
||||
exitNodeAllowLANAccess bool
|
||||
shieldsUp bool
|
||||
runSSH bool
|
||||
runWebClient bool
|
||||
forceReauth bool
|
||||
forceDaemon bool
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
advertiseTags string
|
||||
advertiseConnector bool
|
||||
snat bool
|
||||
netfilterMode string
|
||||
authKeyOrFile string // "secret" or "file:/path/to/secret"
|
||||
@@ -285,14 +276,12 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
|
||||
prefs.AllowSingleHosts = upArgs.singleRoutes
|
||||
prefs.ShieldsUp = upArgs.shieldsUp
|
||||
prefs.RunSSH = upArgs.runSSH
|
||||
prefs.RunWebClient = upArgs.runWebClient
|
||||
prefs.AdvertiseRoutes = routes
|
||||
prefs.AdvertiseTags = tags
|
||||
prefs.Hostname = upArgs.hostname
|
||||
prefs.ForceDaemon = upArgs.forceDaemon
|
||||
prefs.OperatorUser = upArgs.opUser
|
||||
prefs.ProfileName = upArgs.profileName
|
||||
prefs.AppConnector.Advertise = upArgs.advertiseConnector
|
||||
|
||||
if goos == "linux" {
|
||||
prefs.NoSNAT = !upArgs.snat
|
||||
@@ -443,16 +432,9 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
|
||||
}
|
||||
|
||||
if len(prefs.AdvertiseRoutes) > 0 {
|
||||
// TODO(jwhited): compress CheckIPForwarding and CheckUDPGROForwarding
|
||||
// into a single HTTP req.
|
||||
if err := localClient.CheckIPForwarding(ctx); err != nil {
|
||||
if err := localClient.CheckIPForwarding(context.Background()); err != nil {
|
||||
warnf("%v", err)
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if err := localClient.CheckUDPGROForwarding(ctx); err != nil {
|
||||
warnf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
curPrefs, err := localClient.GetPrefs(ctx)
|
||||
@@ -550,11 +532,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
|
||||
// Only need to print an update if we printed the "please click" message earlier.
|
||||
fmt.Fprintf(Stderr, "Success.\n")
|
||||
if cv != nil && !cv.RunningLatest && cv.LatestVersion != "" {
|
||||
if cv.UrgentSecurityUpdate {
|
||||
fmt.Fprintf(Stderr, "\nSecurity update available: %v -> %v\n", version.Short(), cv.LatestVersion)
|
||||
} else {
|
||||
fmt.Fprintf(Stderr, "\nUpdate available: %v -> %v\n", version.Short(), cv.LatestVersion)
|
||||
}
|
||||
fmt.Fprintf(Stderr, "\nUpdate available: %v -> %v\n", version.Short(), cv.LatestVersion)
|
||||
fmt.Fprintln(Stderr, "Changelog: https://tailscale.com/changelog/#client")
|
||||
fmt.Fprintln(Stderr, "Run `tailscale update` or `tailscale set --auto-update` to update")
|
||||
}
|
||||
@@ -744,12 +722,9 @@ func init() {
|
||||
addPrefFlagMapping("unattended", "ForceDaemon")
|
||||
addPrefFlagMapping("operator", "OperatorUser")
|
||||
addPrefFlagMapping("ssh", "RunSSH")
|
||||
addPrefFlagMapping("webclient", "RunWebClient")
|
||||
addPrefFlagMapping("nickname", "ProfileName")
|
||||
addPrefFlagMapping("update-check", "AutoUpdate")
|
||||
addPrefFlagMapping("auto-update", "AutoUpdate")
|
||||
addPrefFlagMapping("advertise-connector", "AppConnector")
|
||||
addPrefFlagMapping("posture-checking", "PostureChecking")
|
||||
}
|
||||
|
||||
func addPrefFlagMapping(flagName string, prefNames ...string) {
|
||||
@@ -953,8 +928,6 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
|
||||
panic(fmt.Sprintf("unhandled flag %q", f.Name))
|
||||
case "ssh":
|
||||
set(prefs.RunSSH)
|
||||
case "webclient":
|
||||
set(prefs.RunWebClient)
|
||||
case "login-server":
|
||||
set(prefs.ControlURL)
|
||||
case "accept-routes":
|
||||
@@ -986,8 +959,6 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
|
||||
set(sb.String())
|
||||
case "advertise-exit-node":
|
||||
set(hasExitNodeRoutes(prefs.AdvertiseRoutes))
|
||||
case "advertise-connector":
|
||||
set(prefs.AppConnector.Advertise)
|
||||
case "snat-subnet-routes":
|
||||
set(!prefs.NoSNAT)
|
||||
case "netfilter-mode":
|
||||
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
var updateCmd = &ffcli.Command{
|
||||
Name: "update",
|
||||
ShortUsage: "update",
|
||||
ShortHelp: "[BETA] Update Tailscale to the latest/different version",
|
||||
ShortHelp: "[ALPHA] Update Tailscale to the latest/different version",
|
||||
Exec: runUpdate,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("update")
|
||||
fs.BoolVar(&updateArgs.yes, "yes", false, "update without interactive prompts")
|
||||
fs.BoolVar(&updateArgs.dryRun, "dry-run", false, "print what update would do without doing it, or prompts")
|
||||
fs.BoolVar(&updateArgs.appStore, "app-store", false, "HIDDEN: check the App Store for updates, even if this is not an App Store install (for testing only)")
|
||||
// These flags are not supported on several systems that only provide
|
||||
// the latest version of Tailscale:
|
||||
//
|
||||
@@ -41,10 +42,11 @@ var updateCmd = &ffcli.Command{
|
||||
}
|
||||
|
||||
var updateArgs struct {
|
||||
yes bool
|
||||
dryRun bool
|
||||
track string // explicit track; empty means same as current
|
||||
version string // explicit version; empty means auto
|
||||
yes bool
|
||||
dryRun bool
|
||||
appStore bool
|
||||
track string // explicit track; empty means same as current
|
||||
version string // explicit version; empty means auto
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, args []string) error {
|
||||
@@ -59,11 +61,10 @@ func runUpdate(ctx context.Context, args []string) error {
|
||||
ver = updateArgs.track
|
||||
}
|
||||
err := clientupdate.Update(clientupdate.Arguments{
|
||||
Version: ver,
|
||||
Logf: func(f string, a ...any) { printf(f+"\n", a...) },
|
||||
Stdout: Stdout,
|
||||
Stderr: Stderr,
|
||||
Confirm: confirmUpdate,
|
||||
Version: ver,
|
||||
AppStore: updateArgs.appStore,
|
||||
Logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
|
||||
Confirm: confirmUpdate,
|
||||
})
|
||||
if errors.Is(err, errors.ErrUnsupported) {
|
||||
return errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
|
||||
@@ -82,14 +83,7 @@ func confirmUpdate(ver string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("This will update Tailscale from %v to %v. Continue?", version.Short(), ver)
|
||||
return promptYesNo(msg)
|
||||
}
|
||||
|
||||
// PromptYesNo takes a question and prompts the user to answer the
|
||||
// question with a yes or no. It appends a [y/n] to the message.
|
||||
func promptYesNo(msg string) bool {
|
||||
fmt.Print(msg + " [y/n] ")
|
||||
fmt.Printf("This will update Tailscale from %v to %v. Continue? [y/n] ", version.Short(), ver)
|
||||
var resp string
|
||||
fmt.Scanln(&resp)
|
||||
resp = strings.ToLower(resp)
|
||||
|
||||
@@ -13,15 +13,11 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/web"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/cmpx"
|
||||
)
|
||||
|
||||
@@ -42,6 +38,7 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
webf := newFlagSet("web")
|
||||
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
|
||||
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
|
||||
webf.BoolVar(&webArgs.dev, "dev", false, "run web client in developer mode [this flag is in development, use is unsupported]")
|
||||
webf.StringVar(&webArgs.prefix, "prefix", "", "URL prefix added to requests (for cgi or reverse proxies)")
|
||||
return webf
|
||||
})(),
|
||||
@@ -51,6 +48,7 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
var webArgs struct {
|
||||
listen string
|
||||
cgi bool
|
||||
dev bool
|
||||
prefix string
|
||||
}
|
||||
|
||||
@@ -78,76 +76,34 @@ func tlsConfigFromEnvironment() *tls.Config {
|
||||
}
|
||||
|
||||
func runWeb(ctx context.Context, args []string) error {
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
var hasPreviewCap bool
|
||||
var selfIP netip.Addr
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err == nil && st.Self != nil && len(st.Self.TailscaleIPs) > 0 {
|
||||
hasPreviewCap = st.Self.HasCap(tailcfg.CapabilityPreviewWebClient)
|
||||
selfIP = st.Self.TailscaleIPs[0]
|
||||
}
|
||||
|
||||
cliServerMode := web.LegacyServerMode
|
||||
var existingWebClient bool
|
||||
if prefs, err := localClient.GetPrefs(ctx); err == nil {
|
||||
existingWebClient = prefs.RunWebClient
|
||||
}
|
||||
if hasPreviewCap {
|
||||
cliServerMode = web.LoginServerMode
|
||||
if !existingWebClient {
|
||||
// Also start full client in tailscaled.
|
||||
log.Printf("starting tailscaled web client at %s:%d\n", selfIP.String(), web.ListenPort)
|
||||
if err := setRunWebClient(ctx, true); err != nil {
|
||||
return fmt.Errorf("starting web client in tailscaled: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webServer, err := web.NewServer(web.ServerOpts{
|
||||
Mode: cliServerMode,
|
||||
webServer, cleanup := web.NewServer(ctx, web.ServerOpts{
|
||||
DevMode: webArgs.dev,
|
||||
CGIMode: webArgs.cgi,
|
||||
PathPrefix: webArgs.prefix,
|
||||
LocalClient: &localClient,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("tailscale.web: %v", err)
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Shutdown the server.
|
||||
webServer.Shutdown()
|
||||
if hasPreviewCap && !webArgs.cgi && !existingWebClient {
|
||||
log.Println("stopping tailscaled web client")
|
||||
// When not in cgi mode, shut down the tailscaled
|
||||
// web client on cli termination.
|
||||
if err := setRunWebClient(context.Background(), false); err != nil {
|
||||
log.Printf("stopping tailscaled web client: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
defer cleanup()
|
||||
|
||||
if webArgs.cgi {
|
||||
if err := cgi.Serve(webServer); err != nil {
|
||||
log.Printf("tailscale.cgi: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else if tlsConfig := tlsConfigFromEnvironment(); tlsConfig != nil {
|
||||
}
|
||||
|
||||
tlsConfig := tlsConfigFromEnvironment()
|
||||
if tlsConfig != nil {
|
||||
server := &http.Server{
|
||||
Addr: webArgs.listen,
|
||||
TLSConfig: tlsConfig,
|
||||
Handler: webServer,
|
||||
}
|
||||
defer server.Shutdown(ctx)
|
||||
|
||||
log.Printf("web server running on: https://%s", server.Addr)
|
||||
return server.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
@@ -156,14 +112,6 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func setRunWebClient(ctx context.Context, val bool) error {
|
||||
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{RunWebClient: val},
|
||||
RunWebClientSet: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// urlOfListenAddr parses a given listen address into a formatted URL
|
||||
func urlOfListenAddr(addr string) string {
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
|
||||
@@ -2,6 +2,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
|
||||
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/negotiate+
|
||||
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,6 +17,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
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+
|
||||
@@ -42,11 +48,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli
|
||||
github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+
|
||||
github.com/skip2/go-qrcode/reedsolomon from github.com/skip2/go-qrcode
|
||||
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+
|
||||
github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+
|
||||
github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||
github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+
|
||||
@@ -64,6 +65,22 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
go4.org/netipx from tailscale.com/wgengine/filter+
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
|
||||
gopkg.in/yaml.v2 from sigs.k8s.io/yaml
|
||||
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+
|
||||
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
|
||||
nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
@@ -116,7 +133,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
|
||||
💣 tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/syncs from tailscale.com/net/netcheck+
|
||||
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
@@ -141,7 +158,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/types/views from tailscale.com/tailcfg+
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/cmpx from tailscale.com/cmd/tailscale/cli+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -152,14 +169,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/mak from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlhttp+
|
||||
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/vizerror from tailscale.com/types/ipproto+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
|
||||
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -2,6 +2,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
|
||||
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
|
||||
@@ -81,7 +86,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
W 💣 github.com/dblohm7/wingoes/com/automation from tailscale.com/util/osdiag/internal/wsc
|
||||
W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com
|
||||
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+
|
||||
LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
|
||||
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
|
||||
@@ -95,8 +99,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
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/clientupdate
|
||||
github.com/gorilla/csrf from tailscale.com/client/web
|
||||
github.com/gorilla/securecookie from github.com/gorilla/csrf
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka+
|
||||
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
|
||||
@@ -130,16 +132,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+
|
||||
L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4
|
||||
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
|
||||
github.com/pkg/errors from github.com/gorilla/csrf
|
||||
W github.com/pkg/errors from github.com/tailscale/certstore
|
||||
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
||||
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
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+
|
||||
github.com/tailscale/golang-x-crypto/acme from tailscale.com/ipn/ipnlocal
|
||||
LD github.com/tailscale/golang-x-crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh
|
||||
LD 💣 github.com/tailscale/golang-x-crypto/internal/alias from github.com/tailscale/golang-x-crypto/chacha20
|
||||
@@ -151,9 +147,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
|
||||
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
|
||||
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
|
||||
github.com/tailscale/hujson from tailscale.com/ipn/conffile
|
||||
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+
|
||||
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
|
||||
💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+
|
||||
W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn
|
||||
💣 github.com/tailscale/wireguard-go/device from tailscale.com/net/tstun+
|
||||
@@ -222,13 +216,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/appc from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/client/tailscale from tailscale.com/derp+
|
||||
tailscale.com/client/tailscale from tailscale.com/derp
|
||||
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
|
||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
|
||||
@@ -246,7 +238,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/control/controlclient+
|
||||
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+
|
||||
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
|
||||
@@ -258,7 +249,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
|
||||
tailscale.com/ipn/store/mem from tailscale.com/ipn/store+
|
||||
L tailscale.com/kube from tailscale.com/ipn/store/kubestore
|
||||
tailscale.com/licenses from tailscale.com/client/web
|
||||
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
||||
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
|
||||
@@ -271,7 +261,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolver from tailscale.com/net/dns
|
||||
tailscale.com/net/dns/resolver from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
@@ -279,14 +269,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns+
|
||||
tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/net/netstat from tailscale.com/ipn/ipnauth+
|
||||
tailscale.com/net/netutil from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/packet from tailscale.com/net/tstun+
|
||||
tailscale.com/net/packet/checksum from tailscale.com/net/tstun
|
||||
tailscale.com/net/ping from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
|
||||
@@ -304,14 +292,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/posture from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/proxymap from tailscale.com/tsd+
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/smallzstd from tailscale.com/control/controlclient+
|
||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/syncs from tailscale.com/net/netcheck+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
|
||||
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
|
||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
@@ -322,7 +309,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
|
||||
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/empty from tailscale.com/ipn+
|
||||
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
|
||||
@@ -343,26 +329,24 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/types/views from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
tailscale.com/util/cmpx from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth+
|
||||
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth
|
||||
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
|
||||
tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale+
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns+
|
||||
tailscale.com/util/mak from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/must from tailscale.com/logpolicy+
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
|
||||
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
|
||||
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/osuser from tailscale.com/ssh/tailssh+
|
||||
W tailscale.com/util/pidowner from tailscale.com/ipn/ipnauth
|
||||
tailscale.com/util/race from tailscale.com/net/dns/resolver
|
||||
tailscale.com/util/racebuild from tailscale.com/logpolicy
|
||||
tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+
|
||||
@@ -370,13 +354,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
|
||||
W tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/util/vizerror from tailscale.com/types/ipproto+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/control/controlclient+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/util/osdiag+
|
||||
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
@@ -403,7 +386,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from github.com/tailscale/golang-x-crypto/ssh+
|
||||
LD golang.org/x/crypto/ed25519 from github.com/tailscale/golang-x-crypto/ssh
|
||||
LD golang.org/x/crypto/ed25519 from golang.org/x/crypto/ssh+
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
@@ -479,7 +462,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
encoding/base32 from tailscale.com/tka+
|
||||
encoding/base64 from encoding/json+
|
||||
encoding/binary from compress/gzip+
|
||||
encoding/gob from github.com/gorilla/securecookie
|
||||
encoding/hex from crypto/x509+
|
||||
encoding/json from expvar+
|
||||
encoding/pem from crypto/tls+
|
||||
@@ -494,7 +476,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
hash/fnv from tailscale.com/wgengine/magicsock+
|
||||
hash/maphash from go4.org/mem
|
||||
html from tailscale.com/ipn/ipnlocal+
|
||||
html/template from github.com/gorilla/csrf
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
io/ioutil from github.com/godbus/dbus/v5+
|
||||
@@ -539,8 +520,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
text/tabwriter from runtime/pprof
|
||||
text/template from html/template
|
||||
text/template/parse from html/template+
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
unicode/utf16 from crypto/x509+
|
||||
|
||||
@@ -29,11 +29,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/cmd/tailscaled/childproc"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/store"
|
||||
@@ -129,7 +127,6 @@ var args struct {
|
||||
tunname string
|
||||
|
||||
cleanup bool
|
||||
confFile string
|
||||
debug string
|
||||
port uint16
|
||||
statepath string
|
||||
@@ -175,7 +172,6 @@ func main() {
|
||||
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
|
||||
flag.BoolVar(&printVersion, "version", false, "print version information and exit")
|
||||
flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support")
|
||||
flag.StringVar(&args.confFile, "config", "", "path to config file")
|
||||
|
||||
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil {
|
||||
beCLI()
|
||||
@@ -343,17 +339,6 @@ func run() error {
|
||||
|
||||
sys := new(tsd.System)
|
||||
|
||||
// Parse config, if specified, to fail early if it's invalid.
|
||||
var conf *conffile.Config
|
||||
if args.confFile != "" {
|
||||
var err error
|
||||
conf, err = conffile.Load(args.confFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
sys.InitialConfig = conf
|
||||
}
|
||||
|
||||
netMon, err := netmon.New(func(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
})
|
||||
@@ -555,10 +540,6 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
|
||||
}
|
||||
sys.Set(store)
|
||||
|
||||
if w, ok := sys.Tun.GetOK(); ok {
|
||||
w.Start()
|
||||
}
|
||||
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logID, sys, opts.LoginFlags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
|
||||
@@ -570,7 +551,6 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
|
||||
if root := lb.TailscaleVarRoot(); root != "" {
|
||||
dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf)
|
||||
}
|
||||
lb.SetWebLocalClient(&tailscale.LocalClient{Socket: args.socketpath, UseSocketOnly: args.socketpath != ""})
|
||||
configureTaildrop(logf, lb)
|
||||
if err := ns.Start(lb); err != nil {
|
||||
log.Fatalf("failed to start netstack: %v", err)
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -300,14 +299,6 @@ func beWindowsSubprocess() bool {
|
||||
}
|
||||
}()
|
||||
|
||||
// Pre-load wintun.dll using a fully-qualified path so that wintun-go
|
||||
// loads our copy and not some (possibly outdated) copy dropped in system32.
|
||||
// (OSS Issue #10023)
|
||||
fqWintunPath := fullyQualifiedWintunPath(log.Printf)
|
||||
if _, err := windows.LoadDLL(fqWintunPath); err != nil {
|
||||
log.Printf("Error pre-loading \"%s\": %v", fqWintunPath, err)
|
||||
}
|
||||
|
||||
sys := new(tsd.System)
|
||||
netMon, err := netmon.New(log.Printf)
|
||||
if err != nil {
|
||||
@@ -516,7 +507,7 @@ func babysitProc(ctx context.Context, args []string, logf logger.Logf) {
|
||||
}
|
||||
|
||||
func uninstallWinTun(logf logger.Logf) {
|
||||
dll := windows.NewLazyDLL(fullyQualifiedWintunPath(logf))
|
||||
dll := windows.NewLazyDLL("wintun.dll")
|
||||
if err := dll.Load(); err != nil {
|
||||
logf("Cannot load wintun.dll for uninstall: %v", err)
|
||||
return
|
||||
@@ -526,16 +517,3 @@ func uninstallWinTun(logf logger.Logf) {
|
||||
err := wintun.Uninstall()
|
||||
logf("Uninstall: %v", err)
|
||||
}
|
||||
|
||||
func fullyQualifiedWintunPath(logf logger.Logf) string {
|
||||
var dir string
|
||||
var buf [windows.MAX_PATH]uint16
|
||||
length := uint32(len(buf))
|
||||
if err := windows.QueryFullProcessImageName(windows.CurrentProcess(), 0, &buf[0], &length); err != nil {
|
||||
logf("QueryFullProcessImageName failed: %v", err)
|
||||
} else {
|
||||
dir = filepath.Dir(windows.UTF16ToString(buf[:length]))
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "wintun.dll")
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// defaultTestArgs contains the default values for all flags in the testing
|
||||
// package. It is used to reset the flag values in testwrapper tests to allow
|
||||
// parsing the flags again.
|
||||
var defaultTestArgs map[string]string
|
||||
|
||||
// initDefaultTestArgs initializes defaultTestArgs.
|
||||
func initDefaultTestArgs() {
|
||||
if defaultTestArgs != nil {
|
||||
return
|
||||
}
|
||||
defaultTestArgs = make(map[string]string)
|
||||
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
defaultTestArgs[f.Name] = f.DefValue
|
||||
})
|
||||
}
|
||||
|
||||
// registerTestFlags registers all flags from the testing package with the
|
||||
// provided flag set. It does so by calling testing.Init() and then iterating
|
||||
// over all flags registered on flag.CommandLine.
|
||||
func registerTestFlags(fs *flag.FlagSet) {
|
||||
testing.Init()
|
||||
type bv interface {
|
||||
IsBoolFlag() bool
|
||||
}
|
||||
|
||||
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
if b, ok := f.Value.(bv); ok && b.IsBoolFlag() {
|
||||
fs.Bool(f.Name, f.DefValue == "true", f.Usage)
|
||||
if name, ok := strings.CutPrefix(f.Name, "test."); ok {
|
||||
fs.Bool(name, f.DefValue == "true", f.Usage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// We don't actually care about the value of the flag, so we just
|
||||
// register it as a string. The values will be passed to `go test` which
|
||||
// will parse and validate them anyway.
|
||||
fs.String(f.Name, f.DefValue, f.Usage)
|
||||
if name, ok := strings.CutPrefix(f.Name, "test."); ok {
|
||||
fs.String(name, f.DefValue, f.Usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// splitArgs splits args into three parts as consumed by go test.
|
||||
//
|
||||
// go test [build/test flags] [packages] [build/test flags & test binary flags]
|
||||
//
|
||||
// We return these as three slices of strings [pre] [pkgs] [post].
|
||||
//
|
||||
// It is used to split the arguments passed to testwrapper into the arguments
|
||||
// passed to go test and the arguments passed to the tests.
|
||||
func splitArgs(args []string) (pre, pkgs, post []string, _ error) {
|
||||
if len(args) == 0 {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
fs := newTestFlagSet()
|
||||
// Parse stops at the first non-flag argument, so this allows us
|
||||
// to parse those as values and then reconstruct them as args.
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Value.String() != f.DefValue && f.DefValue != "false" {
|
||||
pre = append(pre, "-"+f.Name, f.Value.String())
|
||||
} else {
|
||||
pre = append(pre, "-"+f.Name)
|
||||
}
|
||||
})
|
||||
|
||||
// fs.Args() now contains [packages]+[build/test flags & test binary flags],
|
||||
// to split it we need to find the first non-flag argument.
|
||||
rem := fs.Args()
|
||||
ix := slices.IndexFunc(rem, func(s string) bool { return strings.HasPrefix(s, "-") })
|
||||
if ix == -1 {
|
||||
return pre, rem, nil, nil
|
||||
}
|
||||
pkgs = rem[:ix]
|
||||
post = rem[ix:]
|
||||
return pre, pkgs, post, nil
|
||||
}
|
||||
|
||||
func newTestFlagSet() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("testwrapper", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
// Register all flags from the testing package.
|
||||
registerTestFlags(fs)
|
||||
// Also register the -exec flag, which is not part of the testing package.
|
||||
// TODO(maisem): figure out what other flags we need to register explicitly.
|
||||
fs.String("exec", "", "Command to run tests with")
|
||||
fs.Bool("race", false, "build with race detector")
|
||||
return fs
|
||||
}
|
||||
|
||||
// testingVerbose reports whether the test is being run with verbose logging.
|
||||
var testingVerbose = func() bool {
|
||||
verbose := false
|
||||
|
||||
// Likely doesn't matter, but to be correct follow the go flag parsing logic
|
||||
// of overriding previous values.
|
||||
for _, arg := range os.Args[1:] {
|
||||
switch arg {
|
||||
case "-test.v", "--test.v",
|
||||
"-test.v=true", "--test.v=true",
|
||||
"-v", "--v",
|
||||
"-v=true", "--v=true":
|
||||
verbose = true
|
||||
case "-test.v=false", "--test.v=false",
|
||||
"-v=false", "--v=false":
|
||||
verbose = false
|
||||
}
|
||||
}
|
||||
return verbose
|
||||
}()
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
pre, pkgs, post []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "all",
|
||||
in: []string{"-v", "pkg1", "pkg2", "-run", "TestFoo", "-timeout=20s"},
|
||||
pre: []string{"-v"},
|
||||
pkgs: []string{"pkg1", "pkg2"},
|
||||
post: []string{"-run", "TestFoo", "-timeout=20s"},
|
||||
},
|
||||
{
|
||||
name: "only_pkgs",
|
||||
in: []string{"./..."},
|
||||
pkgs: []string{"./..."},
|
||||
},
|
||||
{
|
||||
name: "pkgs_and_post",
|
||||
in: []string{"pkg1", "-run", "TestFoo"},
|
||||
pkgs: []string{"pkg1"},
|
||||
post: []string{"-run", "TestFoo"},
|
||||
},
|
||||
{
|
||||
name: "pkgs_and_post",
|
||||
in: []string{"-v", "pkg2"},
|
||||
pre: []string{"-v"},
|
||||
pkgs: []string{"pkg2"},
|
||||
},
|
||||
{
|
||||
name: "only_args",
|
||||
in: []string{"-v", "-run=TestFoo"},
|
||||
pre: []string{"-run", "TestFoo", "-v"}, // sorted
|
||||
},
|
||||
{
|
||||
name: "space_in_pre_arg",
|
||||
in: []string{"-run", "TestFoo", "./cmd/testwrapper"},
|
||||
pre: []string{"-run", "TestFoo"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
},
|
||||
{
|
||||
name: "space_in_arg",
|
||||
in: []string{"-exec", "sudo -E", "./cmd/testwrapper"},
|
||||
pre: []string{"-exec", "sudo -E"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
},
|
||||
{
|
||||
name: "test-arg",
|
||||
in: []string{"-exec", "sudo -E", "./cmd/testwrapper", "--", "--some-flag"},
|
||||
pre: []string{"-exec", "sudo -E"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
post: []string{"--", "--some-flag"},
|
||||
},
|
||||
{
|
||||
name: "dupe-args",
|
||||
in: []string{"-v", "-v", "-race", "-race", "./cmd/testwrapper", "--", "--some-flag"},
|
||||
pre: []string{"-race", "-v"},
|
||||
pkgs: []string{"./cmd/testwrapper"},
|
||||
post: []string{"--", "--some-flag"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pre, pkgs, post, err := splitArgs(tt.in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(pre, tt.pre) {
|
||||
t.Errorf("pre = %q; want %q", pre, tt.pre)
|
||||
}
|
||||
if !slices.Equal(pkgs, tt.pkgs) {
|
||||
t.Errorf("pattern = %q; want %q", pkgs, tt.pkgs)
|
||||
}
|
||||
if !slices.Equal(post, tt.post) {
|
||||
t.Errorf("post = %q; want %q", post, tt.post)
|
||||
}
|
||||
if t.Failed() {
|
||||
t.Logf("SplitArgs(%q) = %q %q %q", tt.in, pre, pkgs, post)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -70,24 +71,18 @@ var debug = os.Getenv("TS_TESTWRAPPER_DEBUG") != ""
|
||||
// set to true. Package build errors will not emit a testAttempt (as no valid
|
||||
// JSON is produced) but the [os/exec.ExitError] will be returned.
|
||||
// It calls close(ch) when it's done.
|
||||
func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, testArgs []string, ch chan<- *testAttempt) error {
|
||||
func runTests(ctx context.Context, attempt int, pt *packageTests, otherArgs []string, ch chan<- *testAttempt) error {
|
||||
defer close(ch)
|
||||
args := []string{"test"}
|
||||
args = append(args, goTestArgs...)
|
||||
args = append(args, pt.Pattern)
|
||||
args := []string{"test", "-json", pt.Pattern}
|
||||
args = append(args, otherArgs...)
|
||||
if len(pt.Tests) > 0 {
|
||||
runArg := strings.Join(pt.Tests, "|")
|
||||
args = append(args, "--run", runArg)
|
||||
args = append(args, "-run", runArg)
|
||||
}
|
||||
args = append(args, testArgs...)
|
||||
args = append(args, "-json")
|
||||
if debug {
|
||||
fmt.Println("running", strings.Join(args, " "))
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
if len(pt.Tests) > 0 {
|
||||
cmd.Env = append(os.Environ(), "TS_TEST_SHARD=") // clear test shard; run all tests we say to run
|
||||
}
|
||||
r, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Printf("error creating stdout pipe: %v", err)
|
||||
@@ -183,28 +178,54 @@ func runTests(ctx context.Context, attempt int, pt *packageTests, goTestArgs, te
|
||||
}
|
||||
|
||||
func main() {
|
||||
goTestArgs, packages, testArgs, err := splitArgs(os.Args[1:])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
if len(packages) == 0 {
|
||||
fmt.Println("testwrapper: no packages specified")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// We only need to parse the -v flag to figure out whether to print the logs
|
||||
// for a test. We don't need to parse any other flags, so we just use the
|
||||
// flag package to parse the -v flag and then pass the rest of the args
|
||||
// through to 'go test'.
|
||||
// We run `go test -json` which returns the same information as `go test -v`,
|
||||
// but in a machine-readable format. So this flag is only for testwrapper's
|
||||
// output.
|
||||
v := flag.Bool("v", false, "verbose")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Println("usage: testwrapper [testwrapper-flags] [pattern] [build/test flags & test binary flags]")
|
||||
fmt.Println()
|
||||
fmt.Println("testwrapper-flags:")
|
||||
flag.CommandLine.PrintDefaults()
|
||||
fmt.Println()
|
||||
fmt.Println("examples:")
|
||||
fmt.Println("\ttestwrapper -v ./... -count=1")
|
||||
fmt.Println("\ttestwrapper ./pkg/foo -run TestBar -count=1")
|
||||
fmt.Println()
|
||||
fmt.Println("Unlike 'go test', testwrapper requires a package pattern as the first positional argument and only supports a single pattern.")
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
|
||||
fmt.Println("no pattern specified")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
} else if len(args) > 1 && !strings.HasPrefix(args[1], "-") {
|
||||
fmt.Println("expected single pattern")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
pattern, otherArgs := args[0], args[1:]
|
||||
|
||||
type nextRun struct {
|
||||
tests []*packageTests
|
||||
attempt int // starting at 1
|
||||
}
|
||||
firstRun := &nextRun{
|
||||
attempt: 1,
|
||||
|
||||
toRun := []*nextRun{
|
||||
{
|
||||
tests: []*packageTests{{Pattern: pattern}},
|
||||
attempt: 1,
|
||||
},
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
firstRun.tests = append(firstRun.tests, &packageTests{Pattern: pkg})
|
||||
}
|
||||
toRun := []*nextRun{firstRun}
|
||||
printPkgOutcome := func(pkg, outcome string, attempt int) {
|
||||
if outcome == "skip" {
|
||||
fmt.Printf("?\t%s [skipped/no tests] \n", pkg)
|
||||
@@ -242,7 +263,7 @@ func main() {
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(runErr)
|
||||
runErr <- runTests(ctx, thisRun.attempt, pt, goTestArgs, testArgs, ch)
|
||||
runErr <- runTests(ctx, thisRun.attempt, pt, otherArgs, ch)
|
||||
}()
|
||||
|
||||
var failed bool
|
||||
@@ -252,7 +273,7 @@ func main() {
|
||||
// convenient for us to to specify files in tests, so fix tr.pkg
|
||||
// so that subsequent testwrapper attempts run correctly.
|
||||
if tr.pkg == "command-line-arguments" {
|
||||
tr.pkg = packages[0]
|
||||
tr.pkg = pattern
|
||||
}
|
||||
if tr.pkgFinished {
|
||||
if tr.outcome == "fail" && len(toRetry[tr.pkg]) == 0 {
|
||||
@@ -264,7 +285,7 @@ func main() {
|
||||
printPkgOutcome(tr.pkg, tr.outcome, thisRun.attempt)
|
||||
continue
|
||||
}
|
||||
if testingVerbose || tr.outcome == "fail" {
|
||||
if *v || tr.outcome == "fail" {
|
||||
io.Copy(os.Stdout, &tr.logs)
|
||||
}
|
||||
if tr.outcome != "fail" {
|
||||
|
||||
@@ -125,7 +125,6 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
return ns.DialContextTCP(ctx, dst)
|
||||
}
|
||||
sys.NetstackRouter.Set(true)
|
||||
sys.Tun.Get().Start()
|
||||
|
||||
logid := lpc.PublicID
|
||||
srv := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||
|
||||
@@ -481,7 +481,7 @@ func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
|
||||
// control server, and keeping the netmap up to date.
|
||||
func (c *Auto) mapRoutine() {
|
||||
defer close(c.mapDone)
|
||||
mrs := mapRoutineState{
|
||||
mrs := &mapRoutineState{
|
||||
c: c,
|
||||
bo: backoff.NewBackoff("mapRoutine", c.logf, 30*time.Second),
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ import (
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/util/singleflight"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/util/systemd"
|
||||
)
|
||||
|
||||
@@ -67,6 +66,7 @@ type Direct struct {
|
||||
controlKnobs *controlknobs.Knobs // always non-nil
|
||||
serverURL string // URL of the tailcontrol server
|
||||
clock tstime.Clock
|
||||
lastPrintMap time.Time
|
||||
logf logger.Logf
|
||||
netMon *netmon.Monitor // or nil
|
||||
discoPubKey key.DiscoPublic
|
||||
@@ -566,11 +566,6 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
err = errors.New("hostinfo: BackendLogID missing")
|
||||
return regen, opt.URL, nil, err
|
||||
}
|
||||
|
||||
tailnet, err := syspolicy.GetString(syspolicy.Tailnet, "")
|
||||
if err != nil {
|
||||
c.logf("unable to provide Tailnet field in register request. err: %v", err)
|
||||
}
|
||||
now := c.clock.Now().Round(time.Second)
|
||||
request := tailcfg.RegisterRequest{
|
||||
Version: 1,
|
||||
@@ -582,7 +577,6 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
Timestamp: &now,
|
||||
Ephemeral: (opt.Flags & LoginEphemeral) != 0,
|
||||
NodeKeySignature: nodeKeySignature,
|
||||
Tailnet: tailnet,
|
||||
}
|
||||
if opt.Logout {
|
||||
request.Expiry = time.Unix(123, 0) // far in the past
|
||||
@@ -828,7 +822,7 @@ const watchdogTimeout = 120 * time.Second
|
||||
// if the context expires or the server returns an error/closes the connection
|
||||
// and as such always returns a non-nil error.
|
||||
//
|
||||
// If nu is nil, OmitPeers will be set to true.
|
||||
// If cb is nil, OmitPeers will be set to true.
|
||||
func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu NetmapUpdater) error {
|
||||
if isStreaming && nu == nil {
|
||||
panic("cb must be non-nil if isStreaming is true")
|
||||
@@ -968,6 +962,8 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
return nil
|
||||
}
|
||||
|
||||
var mapResIdx int // 0 for first message, then 1+ for deltas
|
||||
|
||||
sess := newMapSession(persist.PrivateNodeKey(), nu, c.controlKnobs)
|
||||
defer sess.Close()
|
||||
sess.cancel = cancel
|
||||
@@ -976,6 +972,17 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
sess.altClock = c.clock
|
||||
sess.machinePubKey = machinePubKey
|
||||
sess.onDebug = c.handleDebugMessage
|
||||
sess.onConciseNetMapSummary = func(summary string) {
|
||||
// Occasionally print the netmap header.
|
||||
// This is handy for debugging, and our logs processing
|
||||
// pipeline depends on it. (TODO: Remove this dependency.)
|
||||
now := c.clock.Now()
|
||||
if now.Sub(c.lastPrintMap) < 5*time.Minute {
|
||||
return
|
||||
}
|
||||
c.lastPrintMap = now
|
||||
c.logf("[v1] new network map[%d]:\n%s", mapResIdx, summary)
|
||||
}
|
||||
sess.onSelfNodeChanged = func(nm *netmap.NetworkMap) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -992,27 +999,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
}
|
||||
c.expiry = nm.Expiry
|
||||
}
|
||||
|
||||
// Create a watchdog timer that breaks the connection if we don't receive a
|
||||
// MapResponse from the network at least once every two minutes. The
|
||||
// watchdog timer is stopped every time we receive a MapResponse (so it
|
||||
// doesn't run when we're processing a MapResponse message, including any
|
||||
// long-running requested operations like Debug.Sleep) and is reset whenever
|
||||
// we go back to blocking on network reads.
|
||||
watchdogTimer, watchdogTimedOut := c.clock.NewTimer(watchdogTimeout)
|
||||
defer watchdogTimer.Stop()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
vlogf("netmap: ending timeout goroutine")
|
||||
return
|
||||
case <-watchdogTimedOut:
|
||||
c.logf("map response long-poll timed out!")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}()
|
||||
sess.StartWatchdog()
|
||||
|
||||
// gotNonKeepAliveMessage is whether we've yet received a MapResponse message without
|
||||
// KeepAlive set.
|
||||
@@ -1025,8 +1012,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
// the same format before just closing the connection.
|
||||
// We can use this same read loop either way.
|
||||
var msg []byte
|
||||
for mapResIdx := 0; mapResIdx == 0 || isStreaming; mapResIdx++ {
|
||||
watchdogTimer.Reset(watchdogTimeout)
|
||||
for ; mapResIdx == 0 || isStreaming; mapResIdx++ {
|
||||
vlogf("netmap: starting size read after %v (poll %v)", time.Since(t0).Round(time.Millisecond), mapResIdx)
|
||||
var siz [4]byte
|
||||
if _, err := io.ReadFull(res.Body, siz[:]); err != nil {
|
||||
@@ -1047,7 +1033,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
vlogf("netmap: decode error: %v")
|
||||
return err
|
||||
}
|
||||
watchdogTimer.Stop()
|
||||
|
||||
metricMapResponseMessages.Add(1)
|
||||
|
||||
@@ -1090,6 +1075,14 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
c.logf("netmap: [unexpected] new dial plan; nowhere to store it")
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case sess.watchdogReset <- struct{}{}:
|
||||
vlogf("netmap: sent timer reset")
|
||||
case <-ctx.Done():
|
||||
c.logf("[v1] netmap: not resetting timer; context done: %v", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
if resp.KeepAlive {
|
||||
metricMapResponseKeepAlives.Add(1)
|
||||
continue
|
||||
@@ -1116,7 +1109,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) error {
|
||||
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug, watchdogReset chan<- struct{}) error {
|
||||
if code := debug.Exit; code != nil {
|
||||
c.logf("exiting process with status %v per controlplane", *code)
|
||||
os.Exit(*code)
|
||||
@@ -1126,7 +1119,7 @@ func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) e
|
||||
envknob.SetNoLogsNoSupport()
|
||||
}
|
||||
if sleep := time.Duration(debug.SleepSeconds * float64(time.Second)); sleep > 0 {
|
||||
if err := sleepAsRequested(ctx, c.logf, sleep, c.clock); err != nil {
|
||||
if err := sleepAsRequested(ctx, c.logf, watchdogReset, sleep, c.clock); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1458,7 +1451,7 @@ func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr
|
||||
// that the client sleep. The complication is that while we're sleeping (if for
|
||||
// a long time), we need to periodically reset the watchdog timer before it
|
||||
// expires.
|
||||
func sleepAsRequested(ctx context.Context, logf logger.Logf, d time.Duration, clock tstime.Clock) error {
|
||||
func sleepAsRequested(ctx context.Context, logf logger.Logf, watchdogReset chan<- struct{}, d time.Duration, clock tstime.Clock) error {
|
||||
const maxSleep = 5 * time.Minute
|
||||
if d > maxSleep {
|
||||
logf("sleeping for %v, capped from server-requested %v ...", maxSleep, d)
|
||||
@@ -1467,13 +1460,25 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, d time.Duration, cl
|
||||
logf("sleeping for server-requested %v ...", d)
|
||||
}
|
||||
|
||||
ticker, tickerChannel := clock.NewTicker(watchdogTimeout / 2)
|
||||
defer ticker.Stop()
|
||||
timer, timerChannel := clock.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timerChannel:
|
||||
return nil
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timerChannel:
|
||||
return nil
|
||||
case <-tickerChannel:
|
||||
select {
|
||||
case watchdogReset <- struct{}{}:
|
||||
case <-timerChannel:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ type mapSession struct {
|
||||
machinePubKey key.MachinePublic
|
||||
altClock tstime.Clock // if nil, regular time is used
|
||||
cancel context.CancelFunc // always non-nil, shuts down caller's base long poll context
|
||||
watchdogReset chan struct{} // send to request that the long poll activity watchdog timeout be reset
|
||||
|
||||
// sessionAliveCtx is a Background-based context that's alive for the
|
||||
// duration of the mapSession that we own the lifetime of. It's closed by
|
||||
@@ -56,19 +57,22 @@ type mapSession struct {
|
||||
sessionAliveCtx context.Context
|
||||
sessionAliveCtxClose context.CancelFunc // closes sessionAliveCtx
|
||||
|
||||
// Optional hooks, guaranteed non-nil (set to no-op funcs) by the
|
||||
// newMapSession constructor. They must be overridden if desired
|
||||
// before the mapSession is used.
|
||||
// Optional hooks, set once before use.
|
||||
|
||||
// onDebug specifies what to do with a *tailcfg.Debug message.
|
||||
onDebug func(context.Context, *tailcfg.Debug) error
|
||||
// If the watchdogReset chan is nil, it's not used. Otherwise it can be sent to
|
||||
// to request that the long poll activity watchdog timeout be reset.
|
||||
onDebug func(_ context.Context, _ *tailcfg.Debug, watchdogReset chan<- struct{}) error
|
||||
|
||||
// onConciseNetMapSummary, if non-nil, is called with the Netmap.VeryConcise summary
|
||||
// whenever a map response is received.
|
||||
onConciseNetMapSummary func(string)
|
||||
|
||||
// onSelfNodeChanged is called before the NetmapUpdater if the self node was
|
||||
// changed.
|
||||
onSelfNodeChanged func(*netmap.NetworkMap)
|
||||
|
||||
// Fields storing state over the course of multiple MapResponses.
|
||||
lastPrintMap time.Time
|
||||
lastNode tailcfg.NodeView
|
||||
peers map[tailcfg.NodeID]*tailcfg.NodeView // pointer to view (oddly). same pointers as sortedPeers.
|
||||
sortedPeers []*tailcfg.NodeView // same pointers as peers, but sorted by Node.ID
|
||||
@@ -101,36 +105,56 @@ func newMapSession(privateNodeKey key.NodePrivate, nu NetmapUpdater, controlKnob
|
||||
publicNodeKey: privateNodeKey.Public(),
|
||||
lastDNSConfig: new(tailcfg.DNSConfig),
|
||||
lastUserProfile: map[tailcfg.UserID]tailcfg.UserProfile{},
|
||||
watchdogReset: make(chan struct{}),
|
||||
|
||||
// Non-nil no-op defaults, to be optionally overridden by the caller.
|
||||
logf: logger.Discard,
|
||||
vlogf: logger.Discard,
|
||||
cancel: func() {},
|
||||
onDebug: func(context.Context, *tailcfg.Debug) error { return nil },
|
||||
onSelfNodeChanged: func(*netmap.NetworkMap) {},
|
||||
logf: logger.Discard,
|
||||
vlogf: logger.Discard,
|
||||
cancel: func() {},
|
||||
onDebug: func(context.Context, *tailcfg.Debug, chan<- struct{}) error { return nil },
|
||||
onConciseNetMapSummary: func(string) {},
|
||||
onSelfNodeChanged: func(*netmap.NetworkMap) {},
|
||||
}
|
||||
ms.sessionAliveCtx, ms.sessionAliveCtxClose = context.WithCancel(context.Background())
|
||||
return ms
|
||||
}
|
||||
|
||||
// occasionallyPrintSummary logs summary at most once very 5 minutes. The
|
||||
// summary is the Netmap.VeryConcise result from the last received map response.
|
||||
func (ms *mapSession) occasionallyPrintSummary(summary string) {
|
||||
// Occasionally print the netmap header.
|
||||
// This is handy for debugging, and our logs processing
|
||||
// pipeline depends on it. (TODO: Remove this dependency.)
|
||||
now := ms.clock().Now()
|
||||
if now.Sub(ms.lastPrintMap) < 5*time.Minute {
|
||||
return
|
||||
}
|
||||
ms.lastPrintMap = now
|
||||
ms.logf("[v1] new network map (periodic):\n%s", summary)
|
||||
}
|
||||
|
||||
func (ms *mapSession) clock() tstime.Clock {
|
||||
return cmpx.Or[tstime.Clock](ms.altClock, tstime.StdClock{})
|
||||
}
|
||||
|
||||
// StartWatchdog starts the session's watchdog timer.
|
||||
// If there's no activity in too long, it tears down the connection.
|
||||
// Call Close to release these resources.
|
||||
func (ms *mapSession) StartWatchdog() {
|
||||
timer, timedOutChan := ms.clock().NewTimer(watchdogTimeout)
|
||||
go func() {
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ms.sessionAliveCtx.Done():
|
||||
ms.vlogf("netmap: ending timeout goroutine")
|
||||
return
|
||||
case <-timedOutChan:
|
||||
ms.logf("map response long-poll timed out!")
|
||||
ms.cancel()
|
||||
return
|
||||
case <-ms.watchdogReset:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timedOutChan:
|
||||
case <-ms.sessionAliveCtx.Done():
|
||||
ms.vlogf("netmap: ending timeout goroutine")
|
||||
return
|
||||
}
|
||||
}
|
||||
ms.vlogf("netmap: reset timeout timer")
|
||||
timer.Reset(watchdogTimeout)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ms *mapSession) Close() {
|
||||
ms.sessionAliveCtxClose()
|
||||
}
|
||||
@@ -145,7 +169,7 @@ func (ms *mapSession) Close() {
|
||||
// is [re]factoring progress enough.
|
||||
func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *tailcfg.MapResponse) error {
|
||||
if debug := resp.Debug; debug != nil {
|
||||
if err := ms.onDebug(ctx, debug); err != nil {
|
||||
if err := ms.onDebug(ctx, debug, ms.watchdogReset); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -176,7 +200,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
ms.updateStateFromResponse(resp)
|
||||
|
||||
if ms.tryHandleIncrementally(resp) {
|
||||
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
|
||||
ms.onConciseNetMapSummary(ms.lastNetmapSummary) // every 5s log
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -186,7 +210,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
|
||||
nm := ms.netmap()
|
||||
ms.lastNetmapSummary = nm.VeryConcise()
|
||||
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
|
||||
ms.onConciseNetMapSummary(ms.lastNetmapSummary)
|
||||
|
||||
// If the self node changed, we might need to update persist.
|
||||
if resp.Node != nil {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build windows
|
||||
//go:build windows && cgo
|
||||
|
||||
// darwin,cgo is also supported by certstore but untested, so it is not enabled.
|
||||
// darwin,cgo is also supported by certstore but machineCertificateSubject will
|
||||
// need to be loaded by a different mechanism, so this is not currently enabled
|
||||
// on darwin.
|
||||
|
||||
package controlclient
|
||||
|
||||
@@ -19,7 +21,7 @@ import (
|
||||
"github.com/tailscale/certstore"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/util/winutil"
|
||||
)
|
||||
|
||||
var getMachineCertificateSubjectOnce struct {
|
||||
@@ -38,7 +40,7 @@ var getMachineCertificateSubjectOnce struct {
|
||||
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
|
||||
func getMachineCertificateSubject() string {
|
||||
getMachineCertificateSubjectOnce.Do(func() {
|
||||
getMachineCertificateSubjectOnce.v, _ = syspolicy.GetString("MachineCertificateSubject", "")
|
||||
getMachineCertificateSubjectOnce.v, _ = winutil.GetRegString("MachineCertificateSubject")
|
||||
})
|
||||
|
||||
return getMachineCertificateSubjectOnce.v
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user