Compare commits
82 Commits
bradfitz/l
...
awly/versi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a0127273 | ||
|
|
ef6a6e94f1 | ||
|
|
44c6909c92 | ||
|
|
c87d58063a | ||
|
|
1a1e0f460a | ||
|
|
e537d304ef | ||
|
|
5de8650466 | ||
|
|
b2b836214c | ||
|
|
8dc6de6f58 | ||
|
|
7e81c83e64 | ||
|
|
cb07ed54c6 | ||
|
|
a05ab9f3bc | ||
|
|
6b956b49e0 | ||
|
|
fbc18410ad | ||
|
|
e5dcf7bdde | ||
|
|
658971d7c0 | ||
|
|
46fd488a6d | ||
|
|
0ecfc1d5c3 | ||
|
|
f0bc95a066 | ||
|
|
191e2ce719 | ||
|
|
7145016414 | ||
|
|
4ce4bb6271 | ||
|
|
f27b2cf569 | ||
|
|
6c0ac8bef3 | ||
|
|
aa5af06165 | ||
|
|
da31ce3a64 | ||
|
|
b370274b29 | ||
|
|
c6a4612915 | ||
|
|
47019ce1f1 | ||
|
|
af49bcaa52 | ||
|
|
673ff2cb0b | ||
|
|
228a82f178 | ||
|
|
6ad54fed00 | ||
|
|
e9de59a315 | ||
|
|
b48b7d82d0 | ||
|
|
e7482f0df0 | ||
|
|
7a725bb4f0 | ||
|
|
535cb6c3f5 | ||
|
|
f2bc54ba15 | ||
|
|
6cc81a6d3e | ||
|
|
80fc32588c | ||
|
|
e5fbe57908 | ||
|
|
b1a0caf056 | ||
|
|
7f16e000c9 | ||
|
|
01604c06d2 | ||
|
|
37863205ec | ||
|
|
0ee4573a41 | ||
|
|
237c6c44cd | ||
|
|
970eb5e784 | ||
|
|
ca4c940a4d | ||
|
|
09fcbae900 | ||
|
|
32ebc03591 | ||
|
|
3a9f5c02bf | ||
|
|
5289cfce33 | ||
|
|
c2b87fcb46 | ||
|
|
d0f2c0664b | ||
|
|
eaf8aa63fc | ||
|
|
d601c81c51 | ||
|
|
c3313133b9 | ||
|
|
66c7af3dd3 | ||
|
|
bd488e4ff8 | ||
|
|
00375f56ea | ||
|
|
7f3208592f | ||
|
|
44175653dc | ||
|
|
3114a1c88d | ||
|
|
3d7fb6c21d | ||
|
|
df4b730438 | ||
|
|
a7c80c332a | ||
|
|
0d86eb9da5 | ||
|
|
ea599b018c | ||
|
|
28ad910840 | ||
|
|
dd842d4d37 | ||
|
|
6f214dec48 | ||
|
|
89953b015b | ||
|
|
93aa8a8cff | ||
|
|
95715c4a12 | ||
|
|
57c5b5a77e | ||
|
|
3df305b764 | ||
|
|
452f900589 | ||
|
|
ed1b935238 | ||
|
|
fde2ba5bb3 | ||
|
|
62d580f0e8 |
2
.github/workflows/checklocks.yml
vendored
2
.github/workflows/checklocks.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
24
.github/workflows/kubemanifests.yaml
vendored
Normal file
24
.github/workflows/kubemanifests.yaml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
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"
|
||||
@@ -1 +1 @@
|
||||
1.51.0
|
||||
1.53.0
|
||||
|
||||
174
appc/appconnector.go
Normal file
174
appc/appconnector.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appc implements App Connectors.
|
||||
// An AppConnector provides DNS domain oriented routing of traffic. An App
|
||||
// Connector becomes a DNS server for a peer, authoritative for the set of
|
||||
// configured domains. DNS resolution of the target domain triggers dynamic
|
||||
// publication of routes to ensure that traffic to the domain is routed through
|
||||
// the App Connector.
|
||||
package appc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// RouteAdvertiser is an interface that allows the AppConnector to advertise
|
||||
// newly discovered routes that need to be served through the AppConnector.
|
||||
type RouteAdvertiser interface {
|
||||
// AdvertiseRoute adds a new route advertisement if the route is not already
|
||||
// being advertised.
|
||||
AdvertiseRoute(netip.Prefix) error
|
||||
}
|
||||
|
||||
// AppConnector is an implementation of an AppConnector that performs
|
||||
// its function as a subsystem inside of a tailscale node. At the control plane
|
||||
// side App Connector routing is configured in terms of domains rather than IP
|
||||
// addresses.
|
||||
// The AppConnectors responsibility inside tailscaled is to apply the routing
|
||||
// and domain configuration as supplied in the map response.
|
||||
// DNS requests for configured domains are observed. If the domains resolve to
|
||||
// routes not yet served by the AppConnector the local node configuration is
|
||||
// updated to advertise the new route.
|
||||
type AppConnector struct {
|
||||
logf logger.Logf
|
||||
routeAdvertiser RouteAdvertiser
|
||||
|
||||
// mu guards the fields that follow
|
||||
mu sync.Mutex
|
||||
// domains is a map of lower case domain names with no trailing dot, to a
|
||||
// list of resolved IP addresses.
|
||||
domains map[string][]netip.Addr
|
||||
}
|
||||
|
||||
// NewAppConnector creates a new AppConnector.
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
|
||||
return &AppConnector{
|
||||
logf: logger.WithPrefix(logf, "appc: "),
|
||||
routeAdvertiser: routeAdvertiser,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDomains replaces the current set of configured domains with the
|
||||
// supplied set of domains. Domains must not contain a trailing dot, and should
|
||||
// be lower case.
|
||||
func (e *AppConnector) UpdateDomains(domains []string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var old map[string][]netip.Addr
|
||||
old, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(d)
|
||||
e.domains[d] = old[d]
|
||||
}
|
||||
e.logf("handling domains: %v", xmaps.Keys(e.domains))
|
||||
}
|
||||
|
||||
// Domains returns the currently configured domain list.
|
||||
func (e *AppConnector) Domains() views.Slice[string] {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
return views.SliceOf(xmaps.Keys(e.domains))
|
||||
}
|
||||
|
||||
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
|
||||
// response is being returned over the PeerAPI. The response is parsed and
|
||||
// matched against the configured domains, if matched the routeAdvertiser is
|
||||
// advised to advertise the discovered route.
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
h, err := p.AnswerHeader()
|
||||
if err == dnsmessage.ErrSectionDone {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if h.Class != dnsmessage.ClassINET {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
domain := h.Name.String()
|
||||
if len(domain) == 0 {
|
||||
return
|
||||
}
|
||||
if domain[len(domain)-1] == '.' {
|
||||
domain = domain[:len(domain)-1]
|
||||
}
|
||||
domain = strings.ToLower(domain)
|
||||
e.logf("[v2] observed DNS response for %s", domain)
|
||||
|
||||
e.mu.Lock()
|
||||
addrs, ok := e.domains[domain]
|
||||
e.mu.Unlock()
|
||||
if !ok {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var addr netip.Addr
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr = netip.AddrFrom4(r.A)
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr = netip.AddrFrom16(r.AAAA)
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slices.Contains(addrs, addr) {
|
||||
continue
|
||||
}
|
||||
// TODO(raggi): check for existing prefixes
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
|
||||
e.logf("failed to advertise route for %v: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
|
||||
e.mu.Lock()
|
||||
e.domains[domain] = append(addrs, addr)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
}
|
||||
118
appc/appconnector_test.go
Normal file
118
appc/appconnector_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func TestUpdateDomains(t *testing.T) {
|
||||
a := NewAppConnector(t.Logf, nil)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
rc := &routeCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// don't re-advertise routes that have already been advertised
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if !slices.Equal(rc.routes, wantRoutes) {
|
||||
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
|
||||
func dnsResponse(domain, address string) []byte {
|
||||
addr := netip.MustParseAddr(address)
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
|
||||
b.EnableCompression()
|
||||
b.StartAnswers()
|
||||
switch addr.BitLen() {
|
||||
case 32:
|
||||
b.AResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AResource{
|
||||
A: addr.As4(),
|
||||
},
|
||||
)
|
||||
case 128:
|
||||
b.AAAAResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeAAAA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AAAAResource{
|
||||
AAAA: addr.As16(),
|
||||
},
|
||||
)
|
||||
default:
|
||||
panic("invalid address length")
|
||||
}
|
||||
return must.Get(b.Finish())
|
||||
}
|
||||
|
||||
// routeCollector is a test helper that collects the list of routes advertised
|
||||
type routeCollector struct {
|
||||
routes []netip.Prefix
|
||||
}
|
||||
|
||||
// routeCollector implements RouteAdvertiser
|
||||
var _ RouteAdvertiser = (*routeCollector)(nil)
|
||||
|
||||
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx)
|
||||
return nil
|
||||
}
|
||||
@@ -1254,9 +1254,6 @@ func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if res.Err != "" {
|
||||
return false, errors.New(res.Err)
|
||||
}
|
||||
|
||||
248
client/web/auth.go
Normal file
248
client/web/auth.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.27",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
@@ -9,6 +9,7 @@ package web
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -18,21 +19,17 @@ import (
|
||||
|
||||
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
|
||||
// are authorized to use the web client.
|
||||
// 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) {
|
||||
// If the user is not authorized to use the client, an error is returned.
|
||||
func authorizeQNAP(r *http.Request) (ar authResponse, err error) {
|
||||
_, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return false
|
||||
return ar, err
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
http.Error(w, "user is not an admin", http.StatusForbidden)
|
||||
return false
|
||||
return ar, errors.New("user is not an admin")
|
||||
}
|
||||
|
||||
return true
|
||||
return authResponse{OK: true}, nil
|
||||
}
|
||||
|
||||
type qnapAuthResponse struct {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -15,9 +16,13 @@ export function apiFetch(
|
||||
): Promise<Response> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams(params)
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
if (synoToken) {
|
||||
nextParams.set("SynoToken", synoToken)
|
||||
} else {
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
}
|
||||
}
|
||||
const search = nextParams.toString()
|
||||
const url = `api${endpoint}${search ? `?${search}` : ""}`
|
||||
@@ -62,6 +67,10 @@ function updateCsrfToken(r: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setSynoToken(token?: string) {
|
||||
synoToken = token
|
||||
}
|
||||
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
|
||||
@@ -1,156 +1,75 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { Footer, Header, IP, State } from "src/components/legacy"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
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 { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
import LegacyClientView from "src/components/views/legacy-client-view"
|
||||
import LoginClientView from "src/components/views/login-client-view"
|
||||
import ReadonlyClientView from "src/components/views/readonly-client-view"
|
||||
import useAuth, { AuthResponse, SessionsCallbacks } from "src/hooks/auth"
|
||||
import useNodeData from "src/hooks/node-data"
|
||||
import ManagementClientView from "./views/management-client-view"
|
||||
|
||||
export default function App() {
|
||||
// TODO(sonia): use isPosting value from useNodeData
|
||||
// to fill loading states.
|
||||
const { data: auth, loading: loadingAuth, sessions } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14">
|
||||
{loadingAuth ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : (
|
||||
<WebClient auth={auth} sessions={sessions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebClient({
|
||||
auth,
|
||||
sessions,
|
||||
}: {
|
||||
auth?: AuthResponse
|
||||
sessions: SessionsCallbacks
|
||||
}) {
|
||||
const { data, refreshData, updateNode } = useNodeData()
|
||||
|
||||
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") ? (
|
||||
<WebClient {...data} />
|
||||
) : (
|
||||
// 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 WebClient(props: NodeData) {
|
||||
const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth()
|
||||
|
||||
if (loadingAuth) {
|
||||
return <div className="text-center py-14">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
|
||||
{props.DebugMode === "full" && auth?.ok ? (
|
||||
<ManagementView {...props} />
|
||||
) : (
|
||||
<ReadonlyView data={props} auth={auth} waitOnAuth={waitOnAuth} />
|
||||
)}
|
||||
<Footer className="mt-20" licensesURL={props.LicensesURL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadonlyView({
|
||||
data,
|
||||
auth,
|
||||
waitOnAuth,
|
||||
}: {
|
||||
data: NodeData
|
||||
auth?: AuthResponse
|
||||
waitOnAuth: () => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-52 mx-auto">
|
||||
<TailscaleLogo />
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={data.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Owned by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{data.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
|
||||
<div className="flex gap-3">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{data.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{data.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.DebugMode === "full" && (
|
||||
<button
|
||||
className="button button-blue ml-6"
|
||||
onClick={() => {
|
||||
window.open(auth?.authUrl, "_blank")
|
||||
waitOnAuth()
|
||||
}}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!data ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginClientView
|
||||
data={data}
|
||||
onLoginClick={() => updateNode({ Reauthenticate: true })}
|
||||
/>
|
||||
) : data.DebugMode === "full" && auth?.ok ? (
|
||||
// Render new client interface in management mode.
|
||||
<ManagementClientView {...data} />
|
||||
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
|
||||
// Render new client interface in readonly mode.
|
||||
<ReadonlyClientView data={data} auth={auth} sessions={sessions} />
|
||||
) : (
|
||||
// Render legacy client interface.
|
||||
<LegacyClientView
|
||||
data={data}
|
||||
refreshData={refreshData}
|
||||
updateNode={updateNode}
|
||||
/>
|
||||
)}
|
||||
{data && <Footer licensesURL={data.LicensesURL} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ManagementView(props: NodeData) {
|
||||
export function Footer(props: { licensesURL: string; className?: string }) {
|
||||
return (
|
||||
<div className="px-5">
|
||||
<div className="flex justify-between mb-12">
|
||||
<TailscaleIcon />
|
||||
<div className="flex">
|
||||
<p className="mr-2">{props.Profile.LoginName}</p>
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
|
||||
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<p className="font-medium ml-3">{props.DeviceName}</p>
|
||||
</div>
|
||||
<p className="tracking-widest">{props.IP}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 pt-2">
|
||||
Tailscale is up and running. You can connect to this device from devices
|
||||
in your tailnet by using its name or IP address.
|
||||
</p>
|
||||
<button className="button button-blue mt-6">Advertise exit node</button>
|
||||
</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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,52 @@ 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,
|
||||
@@ -184,115 +230,3 @@ 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>
|
||||
)
|
||||
}
|
||||
65
client/web/src/components/views/login-client-view.tsx
Normal file
65
client/web/src/components/views/login-client-view.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
35
client/web/src/components/views/management-client-view.tsx
Normal file
35
client/web/src/components/views/management-client-view.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
export default function ManagementClientView(props: NodeData) {
|
||||
return (
|
||||
<div className="px-5 mb-12">
|
||||
<div className="flex justify-between mb-12">
|
||||
<TailscaleIcon />
|
||||
<div className="flex">
|
||||
<p className="mr-2">{props.Profile.LoginName}</p>
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
|
||||
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<p className="font-medium ml-3">{props.DeviceName}</p>
|
||||
</div>
|
||||
<p className="tracking-widest">{props.IP}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 pt-2">
|
||||
Tailscale is up and running. You can connect to this device from devices
|
||||
in your tailnet by using its name or IP address.
|
||||
</p>
|
||||
<button className="button button-blue mt-6">Advertise exit node</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
client/web/src/components/views/readonly-client-view.tsx
Normal file
71
client/web/src/components/views/readonly-client-view.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react"
|
||||
import { AuthResponse, AuthType, SessionsCallbacks } from "src/hooks/auth"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
/**
|
||||
* ReadonlyClientView is rendered when the web interface is either
|
||||
*
|
||||
* 1. being viewed by a user not allowed to manage the node
|
||||
* (e.g. user does not own the node)
|
||||
*
|
||||
* 2. or the user is allowed to manage the node but does not
|
||||
* yet have a valid browser session.
|
||||
*/
|
||||
export default function ReadonlyClientView({
|
||||
data,
|
||||
auth,
|
||||
sessions,
|
||||
}: {
|
||||
data: NodeData
|
||||
auth?: AuthResponse
|
||||
sessions: SessionsCallbacks
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="pb-52 mx-auto">
|
||||
<TailscaleLogo />
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={data.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Managed by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{data.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
|
||||
<div className="flex gap-3">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{data.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{data.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
{auth?.authNeeded == AuthType.tailscale && (
|
||||
<button
|
||||
className="button button-blue ml-6"
|
||||
onClick={() => {
|
||||
sessions
|
||||
.new()
|
||||
.then((url) => window.open(url, "_blank"))
|
||||
.then(() => sessions.wait())
|
||||
}}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
|
||||
export enum AuthType {
|
||||
synology = "synology",
|
||||
tailscale = "tailscale",
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
ok: boolean
|
||||
authUrl?: string
|
||||
authNeeded?: AuthType
|
||||
}
|
||||
|
||||
export type SessionsCallbacks = {
|
||||
new: () => Promise<string> // creates new auth session and returns authURL
|
||||
wait: () => Promise<void> // blocks until auth is completed
|
||||
}
|
||||
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
// for the web client.
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
|
||||
const loadAuth = useCallback((wait?: boolean) => {
|
||||
const url = wait ? "/auth?wait=true" : "/auth"
|
||||
const loadAuth = useCallback(() => {
|
||||
setLoading(true)
|
||||
return apiFetch(url, "GET")
|
||||
return apiFetch("/auth", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setLoading(false)
|
||||
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)
|
||||
@@ -27,11 +47,33 @@ export default function useAuth() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch("/auth/session/new", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.authUrl)
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const waitForSessionCompletion = useCallback(() => {
|
||||
return apiFetch("/auth/session/wait", "GET")
|
||||
.then(() => loadAuth()) // refresh auth data
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth()
|
||||
}, [])
|
||||
|
||||
const waitOnAuth = useCallback(() => loadAuth(true), [])
|
||||
|
||||
return { data, loading, waitOnAuth }
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
sessions: {
|
||||
new: newSession,
|
||||
wait: waitForSessionCompletion,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
19
client/web/src/ui/profile-pic.tsx
Normal file
19
client/web/src/ui/profile-pic.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react"
|
||||
|
||||
export default function ProfilePic({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{url ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
@@ -17,62 +18,44 @@ import (
|
||||
|
||||
// authorizeSynology authenticates the logged-in Synology user and verifies
|
||||
// that they are authorized to use the web client.
|
||||
// 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
|
||||
// The returned authResponse indicates if the user is authorized,
|
||||
// and if additional steps are needed to authenticate the user.
|
||||
// If the user is authenticated, but not authorized to use the client, an error is returned.
|
||||
func authorizeSynology(r *http.Request) (resp authResponse, err error) {
|
||||
if !hasSynoToken(r) {
|
||||
return authResponse{OK: false, AuthNeeded: synoAuth}, nil
|
||||
}
|
||||
|
||||
// authenticate the Synology user
|
||||
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
|
||||
return false
|
||||
return resp, fmt.Errorf("auth: %v: %s", err, out)
|
||||
}
|
||||
user := strings.TrimSpace(string(out))
|
||||
|
||||
// check if the user is in the administrators group
|
||||
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return false
|
||||
return resp, err
|
||||
}
|
||||
if !isAdmin {
|
||||
http.Error(w, "not a member of administrators group", http.StatusForbidden)
|
||||
return false
|
||||
return resp, errors.New("not a member of administrators group")
|
||||
}
|
||||
|
||||
return true
|
||||
return authResponse{OK: true}, nil
|
||||
}
|
||||
|
||||
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
|
||||
func hasSynoToken(r *http.Request) bool {
|
||||
if r.Header.Get("X-Syno-Token") != "" {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
if r.URL.Query().Get("SynoToken") != "" {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
// We need a SynoToken for authenticate.cgi.
|
||||
// So we tell the client to get one.
|
||||
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -31,24 +29,35 @@ 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
|
||||
|
||||
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
|
||||
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
apiHandler http.Handler // serves api endpoints; csrf-protected
|
||||
assetsHandler http.Handler // serves frontend assets
|
||||
assetsCleanup func() // called from Server.Shutdown
|
||||
|
||||
// browserSessions is an in-memory cache of browser sessions for the
|
||||
// full management web client, which is only accessible over Tailscale.
|
||||
@@ -64,9 +73,29 @@ type Server struct {
|
||||
browserSessions sync.Map
|
||||
}
|
||||
|
||||
// ServerMode specifies the mode of a running web.Server.
|
||||
type ServerMode string
|
||||
|
||||
const (
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
// LoginServerMode serves a readonly login client for logging a
|
||||
// node into a tailnet, and viewing a readonly interface of the
|
||||
// node's current Tailscale settings.
|
||||
//
|
||||
// In this mode, API calls are authenticated via platform auth.
|
||||
LoginServerMode ServerMode = "login"
|
||||
|
||||
// ManageServerMode serves a management client for editing tailscale
|
||||
// settings of a node.
|
||||
//
|
||||
// This mode restricts the app to only being assessible over Tailscale,
|
||||
// and API calls are authenticated via browser sessions associated with
|
||||
// the source's Tailscale identity. If the source browser does not have
|
||||
// a valid session, a readonly version of the app is displayed.
|
||||
ManageServerMode ServerMode = "manage"
|
||||
|
||||
// LegacyServerMode serves the legacy web client, visible to users
|
||||
// prior to release of tailscale/corp#14335.
|
||||
LegacyServerMode ServerMode = "legacy"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -74,54 +103,10 @@ var (
|
||||
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.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)
|
||||
}
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
DevMode bool
|
||||
// Mode specifies the mode of web client being constructed.
|
||||
Mode ServerMode
|
||||
|
||||
// CGIMode indicates if the server is running as a CGI script.
|
||||
CGIMode bool
|
||||
@@ -136,15 +121,32 @@ type ServerOpts struct {
|
||||
// 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.
|
||||
func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
|
||||
// If err is empty, s is always non-nil.
|
||||
// ctx is only required to live the duration of the NewServer call,
|
||||
// and not the lifespan of the web server.
|
||||
func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
switch opts.Mode {
|
||||
case LoginServerMode, ManageServerMode, LegacyServerMode:
|
||||
// valid types
|
||||
case "":
|
||||
return nil, fmt.Errorf("must specify a Mode")
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid Mode provided")
|
||||
}
|
||||
if opts.LocalClient == nil {
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
devMode: opts.DevMode,
|
||||
mode: opts.Mode,
|
||||
logf: opts.Logf,
|
||||
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
|
||||
lc: opts.LocalClient,
|
||||
cgiMode: opts.CGIMode,
|
||||
pathPrefix: opts.PathPrefix,
|
||||
@@ -153,8 +155,12 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
|
||||
if s.timeNow == nil {
|
||||
s.timeNow = time.Now
|
||||
}
|
||||
s.tsDebugMode = s.debugMode()
|
||||
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
|
||||
if s.logf == nil {
|
||||
s.logf = log.Printf
|
||||
}
|
||||
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
|
||||
|
||||
var metric string // clientmetric to report on startup
|
||||
|
||||
// Create handler for "/api" requests with CSRF protection.
|
||||
// We don't require secure cookies, since the web client is regularly used
|
||||
@@ -162,31 +168,30 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
|
||||
// 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.tsDebugMode == "login" {
|
||||
// For the login client, we don't serve the full web client API,
|
||||
// only the login endpoints.
|
||||
if s.mode == LoginServerMode {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
s.lc.IncrementCounter(context.Background(), "web_login_client_initialization", 1)
|
||||
metric = "web_login_client_initialization"
|
||||
} else {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
||||
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
|
||||
metric = "web_client_initialization"
|
||||
}
|
||||
|
||||
return s, cleanup
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (s *Server) Shutdown() {
|
||||
s.logf("web.Server: shutting down")
|
||||
if s.assetsCleanup != nil {
|
||||
s.assetsCleanup()
|
||||
}
|
||||
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.
|
||||
@@ -202,10 +207,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if ok := s.authorizeRequest(w, r); !ok {
|
||||
return
|
||||
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
|
||||
@@ -216,13 +254,52 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// requireTailscaleIP redirects an incoming request if the HTTP request was not made to a bare Tailscale IP address.
|
||||
// The request will be redirected to the Tailscale IP, port 5252, with the original request path.
|
||||
// This allows any custom hostname to be used to access the device, but protects against DNS rebinding attacks.
|
||||
// Returns true if the request has been fully handled, either be returning a redirect or an HTTP error.
|
||||
func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (handled bool) {
|
||||
const (
|
||||
ipv4ServiceHost = tsaddr.TailscaleServiceIPString
|
||||
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
|
||||
)
|
||||
// allow requests on quad-100 (or ipv6 equivalent)
|
||||
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
|
||||
return false
|
||||
}
|
||||
|
||||
st, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
if err != nil {
|
||||
s.logf("error getting status: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
|
||||
var ipv4 string // store the first IPv4 address we see for redirect later
|
||||
for _, ip := range st.Self.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
ipv4 = ip.String()
|
||||
}
|
||||
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
newURL := *r.URL
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
|
||||
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
// is authorized to be completed.
|
||||
// It reports true if the request is authorized, and false otherwise.
|
||||
// authorizeRequest manages writing out any relevant authorization
|
||||
// errors to the ResponseWriter itself.
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.tsDebugMode == "full" { // client using tailscale auth
|
||||
if s.mode == ManageServerMode { // client using tailscale auth
|
||||
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -232,16 +309,13 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without browser session.
|
||||
return true
|
||||
case r.URL.Path == "/api/auth":
|
||||
// Endpoint for browser to request auth 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.getTailscaleBrowserSession calls whois again,
|
||||
// TODO(sonia): s.getSession calls whois again,
|
||||
// should try and use the above call instead of running another
|
||||
// localapi request.
|
||||
session, _, err := s.getTailscaleBrowserSession(r)
|
||||
session, _, err := s.getSession(r)
|
||||
if err != nil || !session.isAuthorized(s.timeNow()) {
|
||||
http.Error(w, "no valid session", http.StatusUnauthorized)
|
||||
return false
|
||||
@@ -253,15 +327,13 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
|
||||
}
|
||||
}
|
||||
// Client using system-specific auth.
|
||||
d := distro.Get()
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == httpm.GET:
|
||||
// Don't require authorization for static assets.
|
||||
return true
|
||||
case d == distro.Synology:
|
||||
return authorizeSynology(w, r)
|
||||
case d == distro.QNAP:
|
||||
return authorizeQNAP(w, r)
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
resp, _ := authorizeSynology(r)
|
||||
return resp.OK
|
||||
case distro.QNAP:
|
||||
resp, _ := authorizeQNAP(r)
|
||||
return resp.OK
|
||||
default:
|
||||
return true // no additional auth for this distro
|
||||
}
|
||||
@@ -280,224 +352,117 @@ 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)
|
||||
case httpm.POST:
|
||||
// TODO(soniaappasamy): implement
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
type authType string
|
||||
|
||||
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")
|
||||
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
|
||||
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// - (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) getTailscaleBrowserSession(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
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
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
|
||||
OK bool `json:"ok"` // true when user has valid auth session
|
||||
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
||||
}
|
||||
|
||||
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != httpm.GET {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// serverAPIAuth handles requests to the /api/auth endpoint
|
||||
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
|
||||
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var resp authResponse
|
||||
|
||||
session, whois, err := s.getTailscaleBrowserSession(r)
|
||||
session, _, err := s.getSession(r)
|
||||
switch {
|
||||
case err != nil && errors.Is(err, errNotUsingTailscale):
|
||||
// not using tailscale, so perform platform auth
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
resp, err = authorizeSynology(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
case distro.QNAP:
|
||||
resp, err = authorizeQNAP(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
default:
|
||||
resp.OK = true // no additional auth for this distro
|
||||
}
|
||||
case err != nil && (errors.Is(err, errNotOwner) ||
|
||||
errors.Is(err, errNotUsingTailscale) ||
|
||||
errors.Is(err, errTaggedLocalSource) ||
|
||||
errors.Is(err, errTaggedRemoteSource)):
|
||||
// These cases are all restricted to the readonly view.
|
||||
// No auth action to take.
|
||||
resp = authResponse{OK: false}
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
case session.isAuthorized(s.timeNow()):
|
||||
resp = authResponse{OK: true}
|
||||
default:
|
||||
resp = authResponse{OK: false, AuthNeeded: tailscaleAuth}
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
type newSessionAuthResponse struct {
|
||||
AuthURL string `json:"authUrl,omitempty"`
|
||||
}
|
||||
|
||||
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
||||
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
||||
session, whois, err := s.getSession(r)
|
||||
if err != nil && !errors.Is(err, errNoSession) {
|
||||
// Source associated with request not allowed to create
|
||||
// a session for this web client.
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
case session == nil:
|
||||
}
|
||||
if session == nil {
|
||||
// Create a new session.
|
||||
d, err := s.getOrAwaitAuth(r.Context(), "", whois.Node.ID)
|
||||
// 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
|
||||
}
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: whois.Node.ID,
|
||||
SrcUser: whois.UserProfile.ID,
|
||||
AuthID: d.ID,
|
||||
AuthURL: d.URL,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
s.browserSessions.Store(sid, session)
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: sid,
|
||||
Raw: sid,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
})
|
||||
resp = authResponse{OK: false, AuthURL: d.URL}
|
||||
case !session.isAuthorized(s.timeNow()):
|
||||
if r.URL.Query().Get("wait") == "true" {
|
||||
// Client requested we block until user completes auth.
|
||||
d, err := s.getOrAwaitAuth(r.Context(), session.AuthID, whois.Node.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
// 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
|
||||
}
|
||||
if d.Complete {
|
||||
session.Authenticated = d.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
}
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
resp = authResponse{OK: true}
|
||||
} else {
|
||||
resp = authResponse{OK: false, AuthURL: session.AuthURL}
|
||||
}
|
||||
default:
|
||||
resp = authResponse{OK: true}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return // already authorized
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
// 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
|
||||
if err := s.awaitUserAuth(r.Context(), session); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
@@ -507,11 +472,6 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/auth":
|
||||
if s.tsDebugMode == "full" { // behind debug flag
|
||||
s.serveTailscaleAuth(w, r)
|
||||
return
|
||||
}
|
||||
case path == "/data":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
@@ -560,6 +520,12 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
profile := st.User[st.Self.UserID]
|
||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||
versionShort := strings.Split(st.Version, "-")[0]
|
||||
var debugMode string
|
||||
if s.mode == ManageServerMode {
|
||||
debugMode = "full"
|
||||
} else if s.mode == LoginServerMode {
|
||||
debugMode = "login"
|
||||
}
|
||||
data := &nodeData{
|
||||
Profile: profile,
|
||||
Status: st.BackendState,
|
||||
@@ -571,7 +537,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
IsUnraid: distro.Get() == distro.Unraid,
|
||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||
IPNVersion: versionShort,
|
||||
DebugMode: s.tsDebugMode,
|
||||
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
||||
}
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
@@ -586,11 +552,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
writeJSON(w, *data)
|
||||
}
|
||||
|
||||
type nodeUpdate struct {
|
||||
@@ -645,7 +607,7 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mp.Prefs.WantRunning = true
|
||||
mp.Prefs.AdvertiseRoutes = routes
|
||||
log.Printf("Doing edit: %v", mp.Pretty())
|
||||
s.logf("Doing edit: %v", mp.Pretty())
|
||||
|
||||
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -661,9 +623,9 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if postData.ForceLogout {
|
||||
logout = true
|
||||
}
|
||||
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
s.logf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
|
||||
url, err := s.tailscaleUp(r.Context(), st, postData)
|
||||
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
s.logf("tailscaleUp = (URL %v, %v)", url != "", err)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
@@ -848,3 +810,12 @@ 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,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -302,7 +303,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
}
|
||||
session, _, err := s.getTailscaleBrowserSession(r)
|
||||
session, _, err := s.getSession(r)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||
}
|
||||
@@ -337,9 +338,9 @@ func TestAuthorizeRequest(t *testing.T) {
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
tsDebugMode: "full",
|
||||
timeNow: time.Now,
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
}
|
||||
validCookie := "ts-cookie"
|
||||
s.browserSessions.Store(validCookie, &browserSession{
|
||||
@@ -369,12 +370,6 @@ func TestAuthorizeRequest(t *testing.T) {
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: false,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/auth",
|
||||
reqMethod: httpm.GET,
|
||||
wantOkNotOverTailscale: false,
|
||||
wantOkWithoutSession: true,
|
||||
wantOkWithSession: true,
|
||||
}, {
|
||||
reqPath: "/api/somethingelse",
|
||||
reqMethod: httpm.GET,
|
||||
@@ -414,9 +409,13 @@ func TestAuthorizeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTailscaleAuth(t *testing.T) {
|
||||
func TestServeAuth(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
|
||||
self := &ipnstate.PeerStatus{
|
||||
ID: "self",
|
||||
UserID: user.ID,
|
||||
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
|
||||
}
|
||||
remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
|
||||
remoteIP := "100.100.100.101"
|
||||
|
||||
@@ -434,9 +433,9 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
||||
|
||||
s := &Server{
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
tsDebugMode: "full",
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
}
|
||||
|
||||
successCookie := "ts-cookie-success"
|
||||
@@ -468,18 +467,29 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string
|
||||
query string
|
||||
wantStatus int
|
||||
wantResp *authResponse
|
||||
wantNewCookie bool // new cookie generated
|
||||
wantSession *browserSession // session associated w/ cookie at end of request
|
||||
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: "new-session-created",
|
||||
name: "no-session",
|
||||
path: "/api/auth",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
|
||||
wantNewCookie: false,
|
||||
wantSession: nil,
|
||||
},
|
||||
{
|
||||
name: "new-session",
|
||||
path: "/api/auth/session/new",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID", // gets swapped for newly created ID by test
|
||||
@@ -493,9 +503,10 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "query-existing-incomplete-session",
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess},
|
||||
wantResp: &authResponse{OK: false, AuthNeeded: tailscaleAuth},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -507,13 +518,27 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transition-to-successful-session",
|
||||
cookie: successCookie,
|
||||
// query "wait" indicates the FE wants to make
|
||||
// local api call to wait until session completed.
|
||||
query: "wait=true",
|
||||
name: "existing-session-used",
|
||||
path: "/api/auth/session/new", // should not create new session
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
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,
|
||||
@@ -526,6 +551,7 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "query-existing-complete-session",
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: true},
|
||||
@@ -541,17 +567,18 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "transition-to-failed-session",
|
||||
path: "/api/auth/session/wait",
|
||||
cookie: failureCookie,
|
||||
query: "wait=true",
|
||||
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: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
@@ -565,9 +592,10 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "expired-cookie-gets-new-session",
|
||||
path: "/api/auth/session/new",
|
||||
cookie: expiredCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
|
||||
wantResp: &newSessionAuthResponse{AuthURL: testControlURL + testAuthPath},
|
||||
wantNewCookie: true,
|
||||
wantSession: &browserSession{
|
||||
ID: "GENERATED_ID",
|
||||
@@ -582,12 +610,11 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/api/auth", nil)
|
||||
r.URL.RawQuery = tt.query
|
||||
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.serveTailscaleAuth(w, r)
|
||||
s.serve(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
@@ -595,17 +622,20 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
var gotResp *authResponse
|
||||
var gotResp string
|
||||
if res.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &gotResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp = strings.Trim(string(body), "\n")
|
||||
}
|
||||
if diff := cmp.Diff(gotResp, tt.wantResp); diff != "" {
|
||||
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.
|
||||
@@ -638,6 +668,92 @@ func TestServeTailscaleAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -660,22 +776,13 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
||||
t.Fatalf("/whois call missing \"addr\" query")
|
||||
}
|
||||
if node := whoIs[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")
|
||||
writeJSON(w, &node)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not a node", http.StatusUnauthorized)
|
||||
return
|
||||
case "/localapi/v0/status":
|
||||
status := ipnstate.Status{Self: self()}
|
||||
if err := json.NewEncoder(w).Encode(status); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeJSON(w, ipnstate.Status{Self: self()})
|
||||
return
|
||||
case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
|
||||
type reqData struct {
|
||||
@@ -700,11 +807,7 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
||||
http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
"@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"
|
||||
@@ -59,6 +67,16 @@
|
||||
"@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"
|
||||
@@ -70,18 +88,23 @@
|
||||
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.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==
|
||||
"@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==
|
||||
dependencies:
|
||||
"@babel/template" "^7.22.5"
|
||||
"@babel/types" "^7.22.5"
|
||||
"@babel/template" "^7.22.15"
|
||||
"@babel/types" "^7.23.0"
|
||||
|
||||
"@babel/helper-hoist-variables@^7.22.5":
|
||||
version "7.22.5"
|
||||
@@ -127,6 +150,11 @@
|
||||
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"
|
||||
@@ -155,11 +183,34 @@
|
||||
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/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"
|
||||
@@ -170,18 +221,18 @@
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/traverse@^7.22.10":
|
||||
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==
|
||||
version "7.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
|
||||
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
|
||||
dependencies:
|
||||
"@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/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/helper-hoist-variables" "^7.22.5"
|
||||
"@babel/helper-split-export-declaration" "^7.22.6"
|
||||
"@babel/parser" "^7.22.10"
|
||||
"@babel/types" "^7.22.10"
|
||||
"@babel/parser" "^7.23.0"
|
||||
"@babel/types" "^7.23.0"
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
@@ -194,6 +245,15 @@
|
||||
"@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"
|
||||
@@ -1002,9 +1062,9 @@ gensync@^1.0.0-beta.2:
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-func-name@^2.0.0:
|
||||
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==
|
||||
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==
|
||||
|
||||
glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
@@ -1404,10 +1464,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:
|
||||
version "8.4.27"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057"
|
||||
integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==
|
||||
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==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
@@ -175,6 +175,11 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
return up.updateLinuxBinary, true
|
||||
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
|
||||
}
|
||||
switch {
|
||||
case haveExecutable("pacman"):
|
||||
@@ -237,10 +242,10 @@ 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; no update needed", ver)
|
||||
up.Logf("already running %v version %v; no update needed", up.track, ver)
|
||||
return false
|
||||
case 1:
|
||||
up.Logf("installed version %v is newer than the latest available version %v; no update needed", version.Short(), ver)
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.track, version.Short(), ver)
|
||||
return false
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
@@ -279,6 +284,7 @@ func (up *Updater) updateSynology() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -717,7 +723,12 @@ func (up *Updater) updateWindows() error {
|
||||
}
|
||||
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New("must be run as Administrator")
|
||||
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`)
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
@@ -733,6 +744,7 @@ func (up *Updater) updateWindows() error {
|
||||
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 {
|
||||
@@ -821,6 +833,30 @@ 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" {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -683,3 +685,113 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ 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) {
|
||||
|
||||
23
cmd/k8s-operator/deploy/chart/.helmignore
Normal file
23
cmd/k8s-operator/deploy/chart/.helmignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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/
|
||||
29
cmd/k8s-operator/deploy/chart/Chart.yaml
Normal file
29
cmd/k8s-operator/deploy/chart/Chart.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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 }}
|
||||
90
cmd/k8s-operator/deploy/chart/templates/deployment.yaml
Normal file
90
cmd/k8s-operator/deploy/chart/templates/deployment.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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 }}
|
||||
13
cmd/k8s-operator/deploy/chart/templates/oauth-secret.yaml
Normal file
13
cmd/k8s-operator/deploy/chart/templates/oauth-secret.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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 -}}
|
||||
60
cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
Normal file
60
cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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
|
||||
32
cmd/k8s-operator/deploy/chart/templates/proxy-rbac.yaml
Normal file
32
cmd/k8s-operator/deploy/chart/templates/proxy-rbac.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
45
cmd/k8s-operator/deploy/chart/values.yaml
Normal file
45
cmd/k8s-operator/deploy/chart/values.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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"
|
||||
@@ -67,10 +67,20 @@ func main() {
|
||||
zlog := kzap.NewRaw(opts...).Sugar()
|
||||
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
|
||||
|
||||
// The operator can run either as a plain operator or it can
|
||||
// additionally act as api-server proxy
|
||||
// https://tailscale.com/kb/1236/kubernetes-operator/?q=kubernetes#accessing-the-kubernetes-control-plane-using-an-api-server-proxy.
|
||||
mode := parseAPIProxyMode()
|
||||
if mode == apiserverProxyModeDisabled {
|
||||
hostinfo.SetApp("k8s-operator")
|
||||
} else {
|
||||
hostinfo.SetApp("k8s-operator-proxy")
|
||||
}
|
||||
|
||||
s, tsClient := initTSNet(zlog)
|
||||
defer s.Close()
|
||||
restConfig := config.GetConfigOrDie()
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s)
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
|
||||
}
|
||||
|
||||
@@ -78,7 +88,6 @@ func main() {
|
||||
// CLIENT_ID_FILE and CLIENT_SECRET_FILE environment variables to authenticate
|
||||
// with Tailscale.
|
||||
func initTSNet(zlog *zap.SugaredLogger) (*tsnet.Server, *tailscale.Client) {
|
||||
hostinfo.SetApp("k8s-operator")
|
||||
var (
|
||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
|
||||
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"k8s.io/client-go/transport"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/logger"
|
||||
@@ -84,13 +83,14 @@ func parseAPIProxyMode() apiServerProxyMode {
|
||||
// maybeLaunchAPIServerProxy launches the auth proxy, which is a small HTTP server
|
||||
// that authenticates requests using the Tailscale LocalAPI and then proxies
|
||||
// them to the kube-apiserver.
|
||||
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server) {
|
||||
mode := parseAPIProxyMode()
|
||||
func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config, s *tsnet.Server, mode apiServerProxyMode) {
|
||||
if mode == apiserverProxyModeDisabled {
|
||||
return
|
||||
}
|
||||
hostinfo.SetApp("k8s-operator-proxy")
|
||||
startlog := zlog.Named("launchAPIProxy")
|
||||
if mode == apiserverProxyModeNoAuth {
|
||||
restConfig = rest.AnonymousClientConfig(restConfig)
|
||||
}
|
||||
cfg, err := restConfig.TransportConfig()
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
|
||||
@@ -166,10 +166,11 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
|
||||
logf: logf,
|
||||
lc: lc,
|
||||
rp: &httputil.ReverseProxy{
|
||||
Director: func(r *http.Request) {
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
// Replace the URL with the Kubernetes APIServer.
|
||||
r.URL.Scheme = u.Scheme
|
||||
r.URL.Host = u.Host
|
||||
|
||||
r.Out.URL.Scheme = u.Scheme
|
||||
r.Out.URL.Host = u.Host
|
||||
if mode == apiserverProxyModeNoAuth {
|
||||
// If we are not providing authentication, then we are just
|
||||
// proxying to the Kubernetes API, so we don't need to do
|
||||
@@ -184,18 +185,18 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf,
|
||||
|
||||
// Out of paranoia, remove all authentication headers that might
|
||||
// have been set by the client.
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("Impersonate-Group")
|
||||
r.Header.Del("Impersonate-User")
|
||||
r.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Header {
|
||||
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 {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
r.Header.Del(k)
|
||||
r.Out.Header.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the impersonation headers that we want.
|
||||
if err := addImpersonationHeaders(r); err != nil {
|
||||
if err := addImpersonationHeaders(r.Out); err != nil {
|
||||
panic("failed to add impersonation headers: " + err.Error())
|
||||
}
|
||||
},
|
||||
|
||||
@@ -307,10 +307,10 @@ func (a *tailscaleSTSReconciler) newAuthKey(ctx context.Context, tags []string)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
//go:embed manifests/proxy.yaml
|
||||
//go:embed deploy/manifests/proxy.yaml
|
||||
var proxyYaml []byte
|
||||
|
||||
//go:embed manifests/userspace-proxy.yaml
|
||||
//go:embed deploy/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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,8 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appc implements App Connectors.
|
||||
package appc
|
||||
package main
|
||||
|
||||
import (
|
||||
"expvar"
|
||||
@@ -31,7 +30,7 @@ type target struct {
|
||||
Matching tailcfg.ProtoPortRange
|
||||
}
|
||||
|
||||
// Server implements an App Connector.
|
||||
// Server implements an App Connector as expressed in sniproxy.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // mu guards following fields
|
||||
connectors map[appctype.ConfigID]connector
|
||||
@@ -67,6 +66,7 @@ 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.
|
||||
@@ -193,8 +193,7 @@ func (c *connector) handleDNS(req *dnsmessage.Message, localAddr netip.Addr) (re
|
||||
}
|
||||
|
||||
func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (response []byte, err error) {
|
||||
buf := make([]byte, 1500)
|
||||
resp := dnsmessage.NewBuilder(buf,
|
||||
resp := dnsmessage.NewBuilder(response,
|
||||
dnsmessage.Header{
|
||||
ID: req.Header.ID,
|
||||
Response: true,
|
||||
@@ -203,8 +202,8 @@ func makeDNSResponse(req *dnsmessage.Message, reachableIPs []netip.Addr) (respon
|
||||
resp.EnableCompression()
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
buf, _ = resp.Finish()
|
||||
return buf, nil
|
||||
response, _ = resp.Finish()
|
||||
return response, nil
|
||||
}
|
||||
q := req.Questions[0]
|
||||
err = resp.StartQuestions()
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
@@ -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/metrics"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tsweb"
|
||||
"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.")
|
||||
const configCapKey = "tailscale.com/sniproxy"
|
||||
|
||||
// portForward is the state for a single port forwarding entry, as passed to the --forward flag.
|
||||
type portForward struct {
|
||||
@@ -68,6 +68,7 @@ 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")
|
||||
@@ -77,334 +78,214 @@ func main() {
|
||||
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
|
||||
|
||||
var s server
|
||||
s.ts.Port = uint16(*wgPort)
|
||||
s.ts.Hostname = *hostname
|
||||
defer s.ts.Close()
|
||||
s.ts.Port = uint16(wgPort)
|
||||
s.ts.Hostname = hostname
|
||||
|
||||
lc, err := s.ts.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("LocalClient() failed: %v", err)
|
||||
}
|
||||
s.lc = lc
|
||||
s.initMetrics()
|
||||
s.ts.RegisterFallbackTCPHandler(s.srv.HandleTCPFlow)
|
||||
|
||||
for _, portStr := range strings.Split(*ports, ",") {
|
||||
ln, err := s.ts.Listen("tcp", ":"+portStr)
|
||||
// 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")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("failed listening on port 80: %v", err)
|
||||
}
|
||||
log.Printf("Serving on port %v ...", portStr)
|
||||
go s.serve(ln)
|
||||
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))
|
||||
}()
|
||||
}
|
||||
|
||||
for _, forwStr := range strings.Split(*forwards, ",") {
|
||||
// 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, ",") {
|
||||
if forwStr == "" {
|
||||
continue
|
||||
}
|
||||
forw, err := parseForward(forwStr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Printf("invalid forwarding spec: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
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()
|
||||
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)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
go s.forward(ln, forw)
|
||||
}
|
||||
|
||||
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)
|
||||
if len(forwardConfigFromFlags) == 0 && len(sniConfigFromFlags.IP) == 0 {
|
||||
return // no config specified on the command line
|
||||
}
|
||||
|
||||
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))
|
||||
}()
|
||||
mak.Set(&out.SNIProxy, "flags", sniConfigFromFlags)
|
||||
for i, forward := range forwardConfigFromFlags {
|
||||
mak.Set(&out.DNAT, appctype.ConfigID(fmt.Sprintf("flags_%d", i)), forward)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
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) {
|
||||
func (s *sniproxy) serveDNS(ln net.Listener) {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Printf("serveDNS accept: %v", err)
|
||||
return
|
||||
}
|
||||
go s.serveConn(c)
|
||||
go s.srv.HandleDNS(c.(nettype.ConnPacketConn))
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func (s *sniproxy) 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,10 +4,30 @@
|
||||
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) {
|
||||
@@ -35,3 +55,169 @@ 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,6 +810,9 @@ 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)
|
||||
}
|
||||
@@ -890,6 +893,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
AdvertiseRoutesSet: true,
|
||||
AdvertiseTagsSet: true,
|
||||
AllowSingleHostsSet: true,
|
||||
AppConnectorSet: true,
|
||||
ControlURLSet: true,
|
||||
CorpDNSSet: true,
|
||||
ExitNodeAllowLANAccessSet: true,
|
||||
@@ -1128,6 +1132,49 @@ 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) {
|
||||
|
||||
@@ -45,6 +45,7 @@ type setArgsT struct {
|
||||
hostname string
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
advertiseConnector bool
|
||||
opUser string
|
||||
acceptedRisks string
|
||||
profileName string
|
||||
@@ -67,6 +68,7 @@ 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")
|
||||
@@ -113,6 +115,9 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
Check: setArgs.updateCheck,
|
||||
Apply: setArgs.updateApply,
|
||||
},
|
||||
AppConnector: ipn.AppConnectorPrefs{
|
||||
Advertise: setArgs.advertiseConnector,
|
||||
},
|
||||
PostureChecking: setArgs.postureChecking,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,6 +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")
|
||||
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
@@ -165,6 +166,7 @@ type upArgsT struct {
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
advertiseTags string
|
||||
advertiseConnector bool
|
||||
snat bool
|
||||
netfilterMode string
|
||||
authKeyOrFile string // "secret" or "file:/path/to/secret"
|
||||
@@ -283,6 +285,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
|
||||
prefs.ForceDaemon = upArgs.forceDaemon
|
||||
prefs.OperatorUser = upArgs.opUser
|
||||
prefs.ProfileName = upArgs.profileName
|
||||
prefs.AppConnector.Advertise = upArgs.advertiseConnector
|
||||
|
||||
if goos == "linux" {
|
||||
prefs.NoSNAT = !upArgs.snat
|
||||
@@ -730,6 +733,7 @@ func init() {
|
||||
addPrefFlagMapping("nickname", "ProfileName")
|
||||
addPrefFlagMapping("update-check", "AutoUpdate")
|
||||
addPrefFlagMapping("auto-update", "AutoUpdate")
|
||||
addPrefFlagMapping("advertise-connector", "AppConnector")
|
||||
addPrefFlagMapping("posture-checking", "PostureChecking")
|
||||
}
|
||||
|
||||
@@ -965,6 +969,8 @@ 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":
|
||||
|
||||
@@ -14,10 +14,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/web"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/cmpx"
|
||||
)
|
||||
|
||||
@@ -38,7 +41,6 @@ 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
|
||||
})(),
|
||||
@@ -48,7 +50,6 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
var webArgs struct {
|
||||
listen string
|
||||
cgi bool
|
||||
dev bool
|
||||
prefix string
|
||||
}
|
||||
|
||||
@@ -76,34 +77,74 @@ 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)
|
||||
}
|
||||
|
||||
webServer, cleanup := web.NewServer(web.ServerOpts{
|
||||
DevMode: webArgs.dev,
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
hasPreviewCap := st.Self.HasCap(tailcfg.CapabilityPreviewWebClient)
|
||||
|
||||
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:5252\n", st.Self.TailscaleIPs[0])
|
||||
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,
|
||||
CGIMode: webArgs.cgi,
|
||||
PathPrefix: webArgs.prefix,
|
||||
LocalClient: &localClient,
|
||||
})
|
||||
defer cleanup()
|
||||
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)
|
||||
}()
|
||||
|
||||
if webArgs.cgi {
|
||||
if err := cgi.Serve(webServer); err != nil {
|
||||
log.Printf("tailscale.cgi: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tlsConfig := tlsConfigFromEnvironment()
|
||||
if tlsConfig != nil {
|
||||
} else if tlsConfig := tlsConfigFromEnvironment(); 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 {
|
||||
@@ -112,6 +153,14 @@ 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)
|
||||
|
||||
@@ -95,6 +95,8 @@ 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
|
||||
@@ -128,6 +130,7 @@ 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
|
||||
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
||||
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
@@ -149,6 +152,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
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+
|
||||
@@ -217,10 +221,12 @@ 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/distsign from tailscale.com/clientupdate
|
||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
|
||||
@@ -251,6 +257,7 @@ 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+
|
||||
@@ -263,7 +270,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/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/resolver from tailscale.com/net/dns
|
||||
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+
|
||||
@@ -313,6 +320,7 @@ 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
|
||||
@@ -339,7 +347,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
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+
|
||||
@@ -468,6 +476,7 @@ 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+
|
||||
@@ -482,6 +491,7 @@ 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+
|
||||
@@ -526,6 +536,8 @@ 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,6 +29,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/cmd/tailscaled/childproc"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
@@ -569,6 +570,7 @@ 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,6 +30,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -299,6 +300,14 @@ 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 {
|
||||
@@ -507,7 +516,7 @@ func babysitProc(ctx context.Context, args []string, logf logger.Logf) {
|
||||
}
|
||||
|
||||
func uninstallWinTun(logf logger.Logf) {
|
||||
dll := windows.NewLazyDLL("wintun.dll")
|
||||
dll := windows.NewLazyDLL(fullyQualifiedWintunPath(logf))
|
||||
if err := dll.Load(); err != nil {
|
||||
logf("Cannot load wintun.dll for uninstall: %v", err)
|
||||
return
|
||||
@@ -517,3 +526,16 @@ 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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ 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
|
||||
@@ -829,7 +828,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 cb is nil, OmitPeers will be set to true.
|
||||
// If nu 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")
|
||||
@@ -969,8 +968,6 @@ 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
|
||||
@@ -979,17 +976,6 @@ 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()
|
||||
@@ -1006,7 +992,27 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
}
|
||||
c.expiry = nm.Expiry
|
||||
}
|
||||
sess.StartWatchdog()
|
||||
|
||||
// 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
|
||||
}
|
||||
}()
|
||||
|
||||
// gotNonKeepAliveMessage is whether we've yet received a MapResponse message without
|
||||
// KeepAlive set.
|
||||
@@ -1019,7 +1025,8 @@ 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 || isStreaming; mapResIdx++ {
|
||||
for mapResIdx := 0; mapResIdx == 0 || isStreaming; mapResIdx++ {
|
||||
watchdogTimer.Reset(watchdogTimeout)
|
||||
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 {
|
||||
@@ -1040,6 +1047,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
vlogf("netmap: decode error: %v")
|
||||
return err
|
||||
}
|
||||
watchdogTimer.Stop()
|
||||
|
||||
metricMapResponseMessages.Add(1)
|
||||
|
||||
@@ -1082,14 +1090,6 @@ 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 +1116,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug, watchdogReset chan<- struct{}) error {
|
||||
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) error {
|
||||
if code := debug.Exit; code != nil {
|
||||
c.logf("exiting process with status %v per controlplane", *code)
|
||||
os.Exit(*code)
|
||||
@@ -1126,7 +1126,7 @@ func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug, w
|
||||
envknob.SetNoLogsNoSupport()
|
||||
}
|
||||
if sleep := time.Duration(debug.SleepSeconds * float64(time.Second)); sleep > 0 {
|
||||
if err := sleepAsRequested(ctx, c.logf, watchdogReset, sleep, c.clock); err != nil {
|
||||
if err := sleepAsRequested(ctx, c.logf, sleep, c.clock); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1458,7 +1458,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, watchdogReset chan<- struct{}, d time.Duration, clock tstime.Clock) error {
|
||||
func sleepAsRequested(ctx context.Context, logf logger.Logf, 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,25 +1467,13 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, watchdogReset chan<
|
||||
logf("sleeping for server-requested %v ...", d)
|
||||
}
|
||||
|
||||
ticker, tickerChannel := clock.NewTicker(watchdogTimeout / 2)
|
||||
defer ticker.Stop()
|
||||
timer, timerChannel := clock.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
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()
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timerChannel:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ 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
|
||||
@@ -57,22 +56,19 @@ type mapSession struct {
|
||||
sessionAliveCtx context.Context
|
||||
sessionAliveCtxClose context.CancelFunc // closes sessionAliveCtx
|
||||
|
||||
// Optional hooks, set once before use.
|
||||
// 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.
|
||||
|
||||
// onDebug specifies what to do with a *tailcfg.Debug message.
|
||||
// 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)
|
||||
onDebug func(context.Context, *tailcfg.Debug) error
|
||||
|
||||
// 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
|
||||
@@ -105,54 +101,34 @@ 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, chan<- struct{}) error { return nil },
|
||||
onConciseNetMapSummary: func(string) {},
|
||||
onSelfNodeChanged: func(*netmap.NetworkMap) {},
|
||||
logf: logger.Discard,
|
||||
vlogf: logger.Discard,
|
||||
cancel: func() {},
|
||||
onDebug: func(context.Context, *tailcfg.Debug) error { return nil },
|
||||
onSelfNodeChanged: func(*netmap.NetworkMap) {},
|
||||
}
|
||||
ms.sessionAliveCtx, ms.sessionAliveCtxClose = context.WithCancel(context.Background())
|
||||
return ms
|
||||
}
|
||||
|
||||
func (ms *mapSession) clock() tstime.Clock {
|
||||
return cmpx.Or[tstime.Clock](ms.altClock, tstime.StdClock{})
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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) clock() tstime.Clock {
|
||||
return cmpx.Or[tstime.Clock](ms.altClock, tstime.StdClock{})
|
||||
}
|
||||
|
||||
func (ms *mapSession) Close() {
|
||||
@@ -169,7 +145,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, ms.watchdogReset); err != nil {
|
||||
if err := ms.onDebug(ctx, debug); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -200,7 +176,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
ms.updateStateFromResponse(resp)
|
||||
|
||||
if ms.tryHandleIncrementally(resp) {
|
||||
ms.onConciseNetMapSummary(ms.lastNetmapSummary) // every 5s log
|
||||
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -210,7 +186,7 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
|
||||
nm := ms.netmap()
|
||||
ms.lastNetmapSummary = nm.VeryConcise()
|
||||
ms.onConciseNetMapSummary(ms.lastNetmapSummary)
|
||||
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
|
||||
|
||||
// If the self node changed, we might need to update persist.
|
||||
if resp.Node != nil {
|
||||
|
||||
@@ -56,6 +56,12 @@ type Client struct {
|
||||
MeshKey string // optional; for trusted clients
|
||||
IsProber bool // optional; for probers to optional declare themselves as such
|
||||
|
||||
// WatchConnectionChanges is whether the client wishes to subscribe to
|
||||
// notifications about clients connecting & disconnecting.
|
||||
//
|
||||
// Only trusted connections (using MeshKey) are allowed to use this.
|
||||
WatchConnectionChanges bool
|
||||
|
||||
// BaseContext, if non-nil, returns the base context to use for dialing a
|
||||
// new derp server. If nil, context.Background is used.
|
||||
// In either case, additional timeouts may be added to the base context.
|
||||
@@ -80,6 +86,7 @@ type Client struct {
|
||||
addrFamSelAtomic syncs.AtomicValue[AddressFamilySelector]
|
||||
|
||||
mu sync.Mutex
|
||||
started bool // true upon first connect, never transitions to false
|
||||
preferred bool
|
||||
canAckPings bool
|
||||
closed bool
|
||||
@@ -93,7 +100,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
func (c *Client) String() string {
|
||||
return fmt.Sprintf("<derphttp_client.Client %s url=%s>", c.serverPubKey.ShortString(), c.url)
|
||||
return fmt.Sprintf("<derphttp_client.Client %s url=%s>", c.ServerPublicKey().ShortString(), c.url)
|
||||
}
|
||||
|
||||
// NewRegionClient returns a new DERP-over-HTTP client. It connects lazily.
|
||||
@@ -142,6 +149,15 @@ func NewClient(privateKey key.NodePrivate, serverURL string, logf logger.Logf) (
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// isStarted reports whether this client has been used yet.
|
||||
//
|
||||
// If if reports false, it may still have its exported fields configured.
|
||||
func (c *Client) isStarted() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.started
|
||||
}
|
||||
|
||||
// Connect connects or reconnects to the server, unless already connected.
|
||||
// It returns nil if there was already a good connection, or if one was made.
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
@@ -284,6 +300,7 @@ func useWebsockets() bool {
|
||||
func (c *Client) connect(ctx context.Context, caller string) (client *derp.Client, connGen int, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.started = true
|
||||
if c.closed {
|
||||
return nil, 0, ErrClientClosed
|
||||
}
|
||||
@@ -495,6 +512,13 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
|
||||
}
|
||||
}
|
||||
|
||||
if c.WatchConnectionChanges {
|
||||
if err := derpClient.WatchConnectionChanges(); err != nil {
|
||||
go httpConn.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
c.serverPubKey = derpClient.ServerPublicKey()
|
||||
c.client = derpClient
|
||||
c.netConn = tcpConn
|
||||
@@ -956,22 +980,6 @@ func (c *Client) NotePreferred(v bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// WatchConnectionChanges sends a request to subscribe to
|
||||
// notifications about clients connecting & disconnecting.
|
||||
//
|
||||
// Only trusted connections (using MeshKey) are allowed to use this.
|
||||
func (c *Client) WatchConnectionChanges() error {
|
||||
client, _, err := c.connect(c.newContext(), "derphttp.Client.WatchConnectionChanges")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = client.WatchConnectionChanges()
|
||||
if err != nil {
|
||||
c.closeForReconnect(client)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ClosePeer asks the server to close target's TCP connection.
|
||||
//
|
||||
// Only trusted connections (using MeshKey) are allowed to use this.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -206,3 +207,243 @@ func TestPing(t *testing.T) {
|
||||
t.Fatalf("Ping: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, k key.NodePrivate) (serverURL string, s *derp.Server) {
|
||||
s = derp.NewServer(k, t.Logf)
|
||||
httpsrv := &http.Server{
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
|
||||
Handler: Handler(s),
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp4", "localhost:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverURL = "http://" + ln.Addr().String()
|
||||
s.SetMeshKey("1234")
|
||||
|
||||
go func() {
|
||||
if err := httpsrv.Serve(ln); err != nil {
|
||||
if err == http.ErrServerClosed {
|
||||
t.Logf("server closed")
|
||||
return
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
func newWatcherClient(t *testing.T, watcherPrivateKey key.NodePrivate, serverToWatchURL string) (c *Client) {
|
||||
c, err := NewClient(watcherPrivateKey, serverToWatchURL, t.Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.MeshKey = "1234"
|
||||
return
|
||||
}
|
||||
|
||||
// breakConnection breaks the connection, which should trigger a reconnect.
|
||||
func (c *Client) breakConnection(brokenClient *derp.Client) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.client != brokenClient {
|
||||
return
|
||||
}
|
||||
if c.netConn != nil {
|
||||
c.netConn.Close()
|
||||
c.netConn = nil
|
||||
}
|
||||
c.client = nil
|
||||
}
|
||||
|
||||
// Test that a watcher connection successfully reconnects and processes peer
|
||||
// updates after a different thread breaks and reconnects the connection, while
|
||||
// the watcher is waiting on recv().
|
||||
func TestBreakWatcherConnRecv(t *testing.T) {
|
||||
// Set the wait time before a retry after connection failure to be much lower.
|
||||
// This needs to be early in the test, for defer to run right at the end after
|
||||
// the DERP client has finished.
|
||||
origRetryInterval := retryInterval
|
||||
retryInterval = 50 * time.Millisecond
|
||||
defer func() { retryInterval = origRetryInterval }()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
// Make the watcher server
|
||||
serverPrivateKey1 := key.NewNode()
|
||||
_, s1 := newTestServer(t, serverPrivateKey1)
|
||||
defer s1.Close()
|
||||
|
||||
// Make the watched server
|
||||
serverPrivateKey2 := key.NewNode()
|
||||
serverURL2, s2 := newTestServer(t, serverPrivateKey2)
|
||||
defer s2.Close()
|
||||
|
||||
// Make the watcher (but it is not connected yet)
|
||||
watcher1 := newWatcherClient(t, serverPrivateKey1, serverURL2)
|
||||
defer watcher1.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watcherChan := make(chan int, 1)
|
||||
|
||||
// Start the watcher thread (which connects to the watched server)
|
||||
wg.Add(1) // To avoid using t.Logf after the test ends. See https://golang.org/issue/40343
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var peers int
|
||||
add := func(k key.NodePublic, _ netip.AddrPort) {
|
||||
t.Logf("add: %v", k.ShortString())
|
||||
peers++
|
||||
// Signal that the watcher has run
|
||||
watcherChan <- peers
|
||||
}
|
||||
remove := func(k key.NodePublic) { t.Logf("remove: %v", k.ShortString()); peers-- }
|
||||
|
||||
watcher1.RunWatchConnectionLoop(ctx, serverPrivateKey1.Public(), t.Logf, add, remove)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
// Wait for the watcher to run, then break the connection and check if it
|
||||
// reconnected and received peer updates.
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case peers := <-watcherChan:
|
||||
if peers != 1 {
|
||||
t.Fatal("wrong number of peers added during watcher connection")
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatalf("watcher did not process the peer update")
|
||||
}
|
||||
watcher1.breakConnection(watcher1.client)
|
||||
// re-establish connection by sending a packet
|
||||
watcher1.ForwardPacket(key.NodePublic{}, key.NodePublic{}, []byte("bogus"))
|
||||
|
||||
timer.Reset(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that a watcher connection successfully reconnects and processes peer
|
||||
// updates after a different thread breaks and reconnects the connection, while
|
||||
// the watcher is not waiting on recv().
|
||||
func TestBreakWatcherConn(t *testing.T) {
|
||||
// Set the wait time before a retry after connection failure to be much lower.
|
||||
// This needs to be early in the test, for defer to run right at the end after
|
||||
// the DERP client has finished.
|
||||
origRetryInterval := retryInterval
|
||||
retryInterval = 50 * time.Millisecond
|
||||
defer func() { retryInterval = origRetryInterval }()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
// Make the watcher server
|
||||
serverPrivateKey1 := key.NewNode()
|
||||
_, s1 := newTestServer(t, serverPrivateKey1)
|
||||
defer s1.Close()
|
||||
|
||||
// Make the watched server
|
||||
serverPrivateKey2 := key.NewNode()
|
||||
serverURL2, s2 := newTestServer(t, serverPrivateKey2)
|
||||
defer s2.Close()
|
||||
|
||||
// Make the watcher (but it is not connected yet)
|
||||
watcher1 := newWatcherClient(t, serverPrivateKey1, serverURL2)
|
||||
defer watcher1.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watcherChan := make(chan int, 1)
|
||||
breakerChan := make(chan bool, 1)
|
||||
|
||||
// Start the watcher thread (which connects to the watched server)
|
||||
wg.Add(1) // To avoid using t.Logf after the test ends. See https://golang.org/issue/40343
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var peers int
|
||||
add := func(k key.NodePublic, _ netip.AddrPort) {
|
||||
t.Logf("add: %v", k.ShortString())
|
||||
peers++
|
||||
// Signal that the watcher has run
|
||||
watcherChan <- peers
|
||||
// Wait for breaker to run
|
||||
<-breakerChan
|
||||
}
|
||||
remove := func(k key.NodePublic) { t.Logf("remove: %v", k.ShortString()); peers-- }
|
||||
|
||||
watcher1.RunWatchConnectionLoop(ctx, serverPrivateKey1.Public(), t.Logf, add, remove)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
// Wait for the watcher to run, then break the connection and check if it
|
||||
// reconnected and received peer updates.
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case peers := <-watcherChan:
|
||||
if peers != 1 {
|
||||
t.Fatal("wrong number of peers added during watcher connection")
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatalf("watcher did not process the peer update")
|
||||
}
|
||||
watcher1.breakConnection(watcher1.client)
|
||||
// re-establish connection by sending a packet
|
||||
watcher1.ForwardPacket(key.NodePublic{}, key.NodePublic{}, []byte("bogus"))
|
||||
// signal that the breaker is done
|
||||
breakerChan <- true
|
||||
|
||||
timer.Reset(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func noopAdd(key.NodePublic, netip.AddrPort) {}
|
||||
func noopRemove(key.NodePublic) {}
|
||||
|
||||
func TestRunWatchConnectionLoopServeConnect(t *testing.T) {
|
||||
defer func() { testHookWatchLookConnectResult = nil }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
priv := key.NewNode()
|
||||
serverURL, s := newTestServer(t, priv)
|
||||
defer s.Close()
|
||||
|
||||
pub := priv.Public()
|
||||
|
||||
watcher := newWatcherClient(t, priv, serverURL)
|
||||
defer watcher.Close()
|
||||
|
||||
// Test connecting to ourselves, and that we get hung up on.
|
||||
testHookWatchLookConnectResult = func(err error, wasSelfConnect bool) bool {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("error connecting to server: %v", err)
|
||||
}
|
||||
if !wasSelfConnect {
|
||||
t.Error("wanted self-connect; wasn't")
|
||||
}
|
||||
return false
|
||||
}
|
||||
watcher.RunWatchConnectionLoop(ctx, pub, t.Logf, noopAdd, noopRemove)
|
||||
|
||||
// Test connecting to the server with a zero value for ignoreServerKey,
|
||||
// so we should always connect.
|
||||
testHookWatchLookConnectResult = func(err error, wasSelfConnect bool) bool {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("error connecting to server: %v", err)
|
||||
}
|
||||
if wasSelfConnect {
|
||||
t.Error("wanted normal connect; got self connect")
|
||||
}
|
||||
return false
|
||||
}
|
||||
watcher.RunWatchConnectionLoop(ctx, key.NodePublic{}, t.Logf, noopAdd, noopRemove)
|
||||
}
|
||||
|
||||
@@ -14,25 +14,40 @@ import (
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// RunWatchConnectionLoop loops until ctx is done, sending WatchConnectionChanges and subscribing to
|
||||
// connection changes.
|
||||
var retryInterval = 5 * time.Second
|
||||
|
||||
// testHookWatchLookConnectResult, if non-nil for tests, is called by RunWatchConnectionLoop
|
||||
// with the connect result. If it returns false, the loop ends.
|
||||
var testHookWatchLookConnectResult func(connectError error, wasSelfConnect bool) (keepRunning bool)
|
||||
|
||||
// RunWatchConnectionLoop loops until ctx is done, sending
|
||||
// WatchConnectionChanges and subscribing to connection changes.
|
||||
//
|
||||
// If the server's public key is ignoreServerKey, RunWatchConnectionLoop returns.
|
||||
// If the server's public key is ignoreServerKey, RunWatchConnectionLoop
|
||||
// returns.
|
||||
//
|
||||
// Otherwise, the add and remove funcs are called as clients come & go.
|
||||
//
|
||||
// infoLogf, if non-nil, is the logger to write periodic status
|
||||
// updates about how many peers are on the server. Error log output is
|
||||
// set to the c's logger, regardless of infoLogf's value.
|
||||
// infoLogf, if non-nil, is the logger to write periodic status updates about
|
||||
// how many peers are on the server. Error log output is set to the c's logger,
|
||||
// regardless of infoLogf's value.
|
||||
//
|
||||
// To force RunWatchConnectionLoop to return quickly, its ctx needs to
|
||||
// be closed, and c itself needs to be closed.
|
||||
// To force RunWatchConnectionLoop to return quickly, its ctx needs to be
|
||||
// closed, and c itself needs to be closed.
|
||||
//
|
||||
// It is a fatal error to call this on an already-started Client withoutq having
|
||||
// initialized Client.WatchConnectionChanges to true.
|
||||
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf, add func(key.NodePublic, netip.AddrPort), remove func(key.NodePublic)) {
|
||||
if !c.WatchConnectionChanges {
|
||||
if c.isStarted() {
|
||||
panic("invalid use of RunWatchConnectionLoop on already-started Client without setting Client.RunWatchConnectionLoop")
|
||||
}
|
||||
c.WatchConnectionChanges = true
|
||||
}
|
||||
if infoLogf == nil {
|
||||
infoLogf = logger.Discard
|
||||
}
|
||||
logf := c.logf
|
||||
const retryInterval = 5 * time.Second
|
||||
const statusInterval = 10 * time.Second
|
||||
var (
|
||||
mu sync.Mutex
|
||||
@@ -101,15 +116,21 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
|
||||
}
|
||||
|
||||
for ctx.Err() == nil {
|
||||
err := c.WatchConnectionChanges()
|
||||
// Make sure we're connected before calling s.ServerPublicKey.
|
||||
_, _, err := c.connect(ctx, "RunWatchConnectionLoop")
|
||||
if err != nil {
|
||||
clear()
|
||||
logf("WatchConnectionChanges: %v", err)
|
||||
if f := testHookWatchLookConnectResult; f != nil && !f(err, false) {
|
||||
return
|
||||
}
|
||||
logf("mesh connect: %v", err)
|
||||
sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
if c.ServerPublicKey() == ignoreServerKey {
|
||||
selfConnect := c.ServerPublicKey() == ignoreServerKey
|
||||
if f := testHookWatchLookConnectResult; f != nil && !f(err, selfConnect) {
|
||||
return
|
||||
}
|
||||
if selfConnect {
|
||||
logf("detected self-connect; ignoring host")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,4 +120,4 @@
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-WGZkpffwe4I8FewdBHXGaLbKQP/kHr7UF2lCXBTcNb4=
|
||||
# nix-direnv cache busting line: sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
|
||||
|
||||
6
go.mod
6
go.mod
@@ -65,8 +65,8 @@ require (
|
||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
|
||||
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89
|
||||
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20230919211114-7bcd7bca7bc5
|
||||
github.com/tailscale/wireguard-go v0.0.0-20230929223258-2f6748dc88e7
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2
|
||||
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90
|
||||
github.com/tc-hib/winres v0.2.1
|
||||
github.com/tcnksm/go-httpstat v0.2.0
|
||||
github.com/toqueteos/webbrowser v1.2.0
|
||||
@@ -166,7 +166,7 @@ require (
|
||||
github.com/denis-tingaikin/go-header v0.4.3 // indirect
|
||||
github.com/docker/cli v24.0.6+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.2+incompatible // indirect
|
||||
github.com/docker/docker v24.0.6+incompatible // indirect
|
||||
github.com/docker/docker v24.0.7+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.0 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-WGZkpffwe4I8FewdBHXGaLbKQP/kHr7UF2lCXBTcNb4=
|
||||
sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
|
||||
|
||||
12
go.sum
12
go.sum
@@ -239,8 +239,8 @@ github.com/docker/cli v24.0.6+incompatible h1:fF+XCQCgJjjQNIMjzaSmiKJSCcfcXb3TWT
|
||||
github.com/docker/cli v24.0.6+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
|
||||
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.6+incompatible h1:hceabKCtUgDqPu+qm0NgsaXf28Ljf4/pWFL7xjWWDgE=
|
||||
github.com/docker/docker v24.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
|
||||
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.8.0 h1:YQFtbBQb4VrpoPxhFuzEBPQ9E16qz5SpHLS+uswaCp8=
|
||||
github.com/docker/docker-credential-helpers v0.8.0/go.mod h1:UGFXcuoQ5TxPiB54nHOZ32AWRqQdECoh/Mg0AlEYb40=
|
||||
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
|
||||
@@ -882,10 +882,10 @@ github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89 h1:7xU7AFQE83h0wz/
|
||||
github.com/tailscale/mkctr v0.0.0-20220601142259-c0b937af2e89/go.mod h1:OGMqrTzDqmJkGumUTtOv44Rp3/4xS+QFbE8Rn0AGlaU=
|
||||
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk=
|
||||
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20230919211114-7bcd7bca7bc5 h1:wKUtQPRpjhZZvAuwYRMcjMZnpWSUEJWIbNJmLtDbR0k=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20230919211114-7bcd7bca7bc5/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20230929223258-2f6748dc88e7 h1:P1od5W+cX/LZZyvbKrNUXuuzxensnKEywLhxhPOeHuY=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20230929223258-2f6748dc88e7/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2 h1:KzNItTAwrc5UmP+JpzRa8ZVNs0x/boBXleVXZq4qd/U=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20231103075435-8a84ac6b1db2/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90 h1:lMGYrokOq9NKDw1UMBH7AsS4boZ41jcduvYaRIdedhE=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20231101022006-db7604d1aa90/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
|
||||
|
||||
@@ -335,10 +335,7 @@ func inAzureAppService() bool {
|
||||
}
|
||||
|
||||
func inAWSFargate() bool {
|
||||
if os.Getenv("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return os.Getenv("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE"
|
||||
}
|
||||
|
||||
func inFlyDotIo() bool {
|
||||
@@ -364,10 +361,7 @@ func inKubernetes() bool {
|
||||
}
|
||||
|
||||
func inDockerDesktop() bool {
|
||||
if os.Getenv("TS_HOST_ENV") == "dde" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return os.Getenv("TS_HOST_ENV") == "dde"
|
||||
}
|
||||
|
||||
func inHomeAssistantAddOn() bool {
|
||||
|
||||
@@ -36,6 +36,7 @@ type ConfigVAlpha struct {
|
||||
|
||||
PostureChecking opt.Bool `json:",omitempty"`
|
||||
RunSSHServer opt.Bool `json:",omitempty"` // Tailscale SSH
|
||||
RunWebClient opt.Bool `json:",omitempty"`
|
||||
ShieldsUp opt.Bool `json:",omitempty"`
|
||||
AutoUpdate *AutoUpdatePrefs `json:",omitempty"`
|
||||
ServeConfigTemp *ServeConfig `json:",omitempty"` // TODO(bradfitz,maisem): make separate stable type for this
|
||||
@@ -113,6 +114,10 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
|
||||
mp.RunSSH = c.RunSSHServer.EqualBool(true)
|
||||
mp.RunSSHSet = true
|
||||
}
|
||||
if c.RunWebClient != "" {
|
||||
mp.RunWebClient = c.RunWebClient.EqualBool(true)
|
||||
mp.RunWebClientSet = true
|
||||
}
|
||||
if c.ShieldsUp != "" {
|
||||
mp.ShieldsUp = c.ShieldsUp.EqualBool(true)
|
||||
mp.ShieldsUpSet = true
|
||||
|
||||
@@ -38,6 +38,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
|
||||
ExitNodeAllowLANAccess bool
|
||||
CorpDNS bool
|
||||
RunSSH bool
|
||||
RunWebClient bool
|
||||
WantRunning bool
|
||||
LoggedOut bool
|
||||
ShieldsUp bool
|
||||
@@ -52,6 +53,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
Persist *persist.Persist
|
||||
}{})
|
||||
|
||||
@@ -71,6 +71,7 @@ func (v PrefsView) ExitNodeIP() netip.Addr { return v.ж.ExitNodeIP
|
||||
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
|
||||
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
|
||||
func (v PrefsView) RunSSH() bool { return v.ж.RunSSH }
|
||||
func (v PrefsView) RunWebClient() bool { return v.ж.RunWebClient }
|
||||
func (v PrefsView) WantRunning() bool { return v.ж.WantRunning }
|
||||
func (v PrefsView) LoggedOut() bool { return v.ж.LoggedOut }
|
||||
func (v PrefsView) ShieldsUp() bool { return v.ж.ShieldsUp }
|
||||
@@ -87,6 +88,7 @@ func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.Netfilte
|
||||
func (v PrefsView) OperatorUser() string { return v.ж.OperatorUser }
|
||||
func (v PrefsView) ProfileName() string { return v.ж.ProfileName }
|
||||
func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate }
|
||||
func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector }
|
||||
func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
|
||||
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
|
||||
|
||||
@@ -100,6 +102,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
|
||||
ExitNodeAllowLANAccess bool
|
||||
CorpDNS bool
|
||||
RunSSH bool
|
||||
RunWebClient bool
|
||||
WantRunning bool
|
||||
LoggedOut bool
|
||||
ShieldsUp bool
|
||||
@@ -114,6 +117,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
Persist *persist.Persist
|
||||
}{})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/winutil"
|
||||
)
|
||||
|
||||
// GetConnIdentity extracts the identity information from the connection
|
||||
@@ -64,7 +65,28 @@ func (t *token) IsAdministrator() (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return t.t.IsMember(baSID)
|
||||
isMember, err := t.t.IsMember(baSID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if isMember {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
isLimited, err := winutil.IsTokenLimited(t.t)
|
||||
if err != nil || !isLimited {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Try to obtain a linked token, and if present, check it.
|
||||
// (This should be the elevated token associated with limited UAC accounts.)
|
||||
linkedToken, err := t.t.GetLinkedToken()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer linkedToken.Close()
|
||||
|
||||
return linkedToken.IsMember(baSID)
|
||||
}
|
||||
|
||||
func (t *token) IsElevated() bool {
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"go4.org/netipx"
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"tailscale.com/appc"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/control/controlknobs"
|
||||
@@ -66,6 +67,7 @@ import (
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/empty"
|
||||
"tailscale.com/types/key"
|
||||
@@ -168,6 +170,7 @@ type LocalBackend struct {
|
||||
logFlushFunc func() // or nil if SetLogFlusher wasn't called
|
||||
em *expiryManager // non-nil
|
||||
sshAtomicBool atomic.Bool
|
||||
webClientAtomicBool atomic.Bool
|
||||
shutdownCalled bool // if Shutdown has been called
|
||||
debugSink *capture.Sink
|
||||
sockstatLogger *sockstatlog.Logger
|
||||
@@ -202,9 +205,11 @@ type LocalBackend struct {
|
||||
conf *conffile.Config // latest parsed config, or nil if not in declarative mode
|
||||
pm *profileManager // mu guards access
|
||||
filterHash deephash.Sum
|
||||
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
|
||||
ccGen clientGen // function for producing controlclient; lazily populated
|
||||
sshServer SSHServer // or nil, initialized lazily.
|
||||
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
|
||||
ccGen clientGen // function for producing controlclient; lazily populated
|
||||
sshServer SSHServer // or nil, initialized lazily.
|
||||
appConnector *appc.AppConnector // or nil, initialized when configured.
|
||||
webClient webClient
|
||||
notify func(ipn.Notify)
|
||||
cc controlclient.Client
|
||||
ccAuto *controlclient.Auto // if cc is of type *controlclient.Auto
|
||||
@@ -532,7 +537,7 @@ func (b *LocalBackend) SetDirectFileDoFinalRename(v bool) {
|
||||
b.directFileDoFinalRename = v
|
||||
}
|
||||
|
||||
// ReloadCOnfig reloads the backend's config from disk.
|
||||
// ReloadConfig reloads the backend's config from disk.
|
||||
//
|
||||
// It returns (false, nil) if not running in declarative mode, (true, nil) on
|
||||
// success, or (false, error) on failure.
|
||||
@@ -643,6 +648,7 @@ func (b *LocalBackend) Shutdown() {
|
||||
b.debugSink = nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.WebClientShutdown()
|
||||
|
||||
if b.sockstatLogger != nil {
|
||||
b.sockstatLogger.Shutdown()
|
||||
@@ -1107,6 +1113,7 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
|
||||
// Perform all reconfiguration based on the netmap here.
|
||||
if st.NetMap != nil {
|
||||
b.capTailnetLock = hasCapability(st.NetMap, tailcfg.CapabilityTailnetLock)
|
||||
b.setWebClientAtomicBoolLocked(st.NetMap, prefs.View())
|
||||
|
||||
b.mu.Unlock() // respect locking rules for tkaSyncIfNeeded
|
||||
if err := b.tkaSyncIfNeeded(st.NetMap, prefs.View()); err != nil {
|
||||
@@ -2498,6 +2505,7 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
|
||||
// and shouldInterceptTCPPortAtomic from the prefs p, which may be !Valid().
|
||||
func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
|
||||
b.sshAtomicBool.Store(p.Valid() && p.RunSSH() && envknob.CanSSHD())
|
||||
b.setWebClientAtomicBoolLocked(b.netMap, p)
|
||||
|
||||
if !p.Valid() {
|
||||
b.containsViaIPFuncAtomic.Store(tsaddr.FalseContainsIPFunc())
|
||||
@@ -2991,6 +2999,9 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
|
||||
|
||||
oldHi := b.hostinfo
|
||||
newHi := oldHi.Clone()
|
||||
if newHi == nil {
|
||||
newHi = new(tailcfg.Hostinfo)
|
||||
}
|
||||
b.applyPrefsToHostinfoLocked(newHi, newp.View())
|
||||
b.hostinfo = newHi
|
||||
hostInfoChanged := !oldHi.Equal(newHi)
|
||||
@@ -3102,6 +3113,9 @@ var (
|
||||
// apply to the socket before calling the handler.
|
||||
func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c net.Conn) error, opts []tcpip.SettableSocketOption) {
|
||||
if dst.Port() == 80 && (dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6) {
|
||||
if b.ShouldRunWebClient() {
|
||||
return b.handleWebClientConn, opts
|
||||
}
|
||||
return b.HandleQuad100Port80Conn, opts
|
||||
}
|
||||
if !b.isLocalIP(dst.Addr()) {
|
||||
@@ -3117,6 +3131,10 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
|
||||
opts = append(opts, ptr.To(tcpip.KeepaliveIdleOption(72*time.Hour)))
|
||||
return b.handleSSHConn, opts
|
||||
}
|
||||
// TODO(will,sonia): allow customizing web client port ?
|
||||
if dst.Port() == webClientPort && b.ShouldRunWebClient() {
|
||||
return b.handleWebClientConn, opts
|
||||
}
|
||||
if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port {
|
||||
return func(c net.Conn) error {
|
||||
b.handlePeerAPIConn(src, dst, c)
|
||||
@@ -3149,6 +3167,12 @@ func (b *LocalBackend) peerAPIServicesLocked() (ret []tailcfg.Service) {
|
||||
Port: 1, // version
|
||||
})
|
||||
}
|
||||
if b.appConnector != nil {
|
||||
ret = append(ret, tailcfg.Service{
|
||||
Proto: tailcfg.AppConnector,
|
||||
Port: 1, // version
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -3217,6 +3241,49 @@ func (b *LocalBackend) blockEngineUpdates(block bool) {
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// reconfigAppConnectorLocked updates the app connector state based on the
|
||||
// current network map and preferences.
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) reconfigAppConnectorLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
|
||||
const appConnectorCapName = "tailscale.com/app-connectors"
|
||||
|
||||
if !prefs.AppConnector().Advertise {
|
||||
b.appConnector = nil
|
||||
return
|
||||
}
|
||||
|
||||
if b.appConnector == nil {
|
||||
b.appConnector = appc.NewAppConnector(b.logf, b)
|
||||
}
|
||||
if nm == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(raggi): rework the view infrastructure so the large deep clone is no
|
||||
// longer required
|
||||
sn := nm.SelfNode.AsStruct()
|
||||
attrs, err := tailcfg.UnmarshalNodeCapJSON[appctype.AppConnectorAttr](sn.CapMap, appConnectorCapName)
|
||||
if err != nil {
|
||||
b.logf("[unexpected] error parsing app connector mapcap: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var domains []string
|
||||
for _, attr := range attrs {
|
||||
// Geometric cost, assumes that the number of advertised tags is small
|
||||
if !nm.SelfNode.Tags().ContainsFunc(func(tag string) bool {
|
||||
return slices.Contains(attr.Connectors, tag)
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
|
||||
domains = append(domains, attr.Domains...)
|
||||
}
|
||||
slices.Sort(domains)
|
||||
slices.Compact(domains)
|
||||
b.appConnector.UpdateDomains(domains)
|
||||
}
|
||||
|
||||
// authReconfig pushes a new configuration into wgengine, if engine
|
||||
// updates are not currently blocked, based on the cached netmap and
|
||||
// user prefs.
|
||||
@@ -3229,6 +3296,8 @@ func (b *LocalBackend) authReconfig() {
|
||||
disableSubnetsIfPAC := hasCapability(nm, tailcfg.NodeAttrDisableSubnetsIfPAC)
|
||||
dohURL, dohURLOK := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID())
|
||||
dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.logf, version.OS())
|
||||
// If the current node is an app connector, ensure the app connector machine is started
|
||||
b.reconfigAppConnectorLocked(nm, prefs)
|
||||
b.mu.Unlock()
|
||||
|
||||
if blocked {
|
||||
@@ -4144,6 +4213,19 @@ func (b *LocalBackend) ResetForClientDisconnect() {
|
||||
|
||||
func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && envknob.CanSSHD() }
|
||||
|
||||
// ShouldRunWebClient reports whether the web client is being run
|
||||
// within this tailscaled instance. ShouldRunWebClient is safe to
|
||||
// call regardless of whether b.mu is held or not.
|
||||
func (b *LocalBackend) ShouldRunWebClient() bool { return b.webClientAtomicBool.Load() }
|
||||
|
||||
func (b *LocalBackend) setWebClientAtomicBoolLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
|
||||
shouldRun := prefs.Valid() && prefs.RunWebClient() && hasCapability(nm, tailcfg.CapabilityPreviewWebClient)
|
||||
wasRunning := b.webClientAtomicBool.Swap(shouldRun)
|
||||
if wasRunning && !shouldRun {
|
||||
go b.WebClientShutdown() // stop web client
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldHandleViaIP reports whether ip is an IPv6 address in the
|
||||
// Tailscale ULA's v6 "via" range embedding an IPv4 address to be forwarded to
|
||||
// by Tailscale.
|
||||
@@ -4391,6 +4473,9 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
|
||||
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
|
||||
handlePorts = append(handlePorts, 22)
|
||||
}
|
||||
if b.ShouldRunWebClient() {
|
||||
handlePorts = append(handlePorts, webClientPort)
|
||||
}
|
||||
|
||||
b.reloadServeConfigLocked(prefs)
|
||||
if b.serveConfig.Valid() {
|
||||
@@ -4797,6 +4882,14 @@ func (b *LocalBackend) OfferingExitNode() bool {
|
||||
return def4 && def6
|
||||
}
|
||||
|
||||
// OfferingAppConnector reports whether b is currently offering app
|
||||
// connector services.
|
||||
func (b *LocalBackend) OfferingAppConnector() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.appConnector != nil
|
||||
}
|
||||
|
||||
// allowExitNodeDNSProxyToServeName reports whether the Exit Node DNS
|
||||
// proxy is allowed to serve responses for the provided DNS name.
|
||||
func (b *LocalBackend) allowExitNodeDNSProxyToServeName(name string) bool {
|
||||
@@ -5383,6 +5476,39 @@ func (b *LocalBackend) DebugBreakDERPConns() error {
|
||||
return b.magicConn().DebugBreakDERPConns()
|
||||
}
|
||||
|
||||
// ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the
|
||||
// App Connector to enable route discovery.
|
||||
func (b *LocalBackend) ObserveDNSResponse(res []byte) {
|
||||
var appConnector *appc.AppConnector
|
||||
b.mu.Lock()
|
||||
if b.appConnector == nil {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
appConnector = b.appConnector
|
||||
b.mu.Unlock()
|
||||
|
||||
appConnector.ObserveDNSResponse(res)
|
||||
}
|
||||
|
||||
// AdvertiseRoute implements the appc.RouteAdvertiser interface. It sets a new
|
||||
// route advertisement if one is not already present in the existing routes.
|
||||
func (b *LocalBackend) AdvertiseRoute(ipp netip.Prefix) error {
|
||||
currentRoutes := b.Prefs().AdvertiseRoutes()
|
||||
// TODO(raggi): check if the new route is a subset of an existing route.
|
||||
if currentRoutes.ContainsFunc(func(r netip.Prefix) bool { return r == ipp }) {
|
||||
return nil
|
||||
}
|
||||
routes := append(currentRoutes.AsSlice(), ipp)
|
||||
_, err := b.EditPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
AdvertiseRoutes: routes,
|
||||
},
|
||||
AdvertiseRoutesSet: true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// mayDeref dereferences p if non-nil, otherwise it returns the zero value.
|
||||
func mayDeref[T any](p *T) (v T) {
|
||||
if p == nil {
|
||||
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go4.org/netipx"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
@@ -30,6 +33,7 @@ import (
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/filter"
|
||||
@@ -1142,6 +1146,112 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferingAppConnector(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
if b.OfferingAppConnector() {
|
||||
t.Fatal("unexpected offering app connector")
|
||||
}
|
||||
b.appConnector = appc.NewAppConnector(t.Logf, nil)
|
||||
if !b.OfferingAppConnector() {
|
||||
t.Fatal("unexpected not offering app connector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConnectorHostinfoService(t *testing.T) {
|
||||
hasAppConnectorService := func(s []tailcfg.Service) bool {
|
||||
for _, s := range s {
|
||||
if s.Proto == tailcfg.AppConnector && s.Port == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
b := newTestBackend(t)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if hasAppConnectorService(b.peerAPIServicesLocked()) {
|
||||
t.Fatal("unexpected app connector service")
|
||||
}
|
||||
b.appConnector = appc.NewAppConnector(t.Logf, nil)
|
||||
if !hasAppConnectorService(b.peerAPIServicesLocked()) {
|
||||
t.Fatal("expected app connector service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteAdvertiser(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
testPrefix := netip.MustParsePrefix("192.0.0.8/32")
|
||||
|
||||
ra := appc.RouteAdvertiser(b)
|
||||
must.Do(ra.AdvertiseRoute(testPrefix))
|
||||
|
||||
routes := b.Prefs().AdvertiseRoutes()
|
||||
if routes.Len() != 1 || routes.At(0) != testPrefix {
|
||||
t.Fatalf("got routes %v, want %v", routes, []netip.Prefix{testPrefix})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
// ensure no error when no app connector is configured
|
||||
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
|
||||
rc := &routeCollector{}
|
||||
b.appConnector = appc.NewAppConnector(t.Logf, rc)
|
||||
b.appConnector.UpdateDomains([]string{"example.com"})
|
||||
|
||||
b.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
if !slices.Equal(rc.routes, wantRoutes) {
|
||||
t.Fatalf("got routes %v, want %v", rc.routes, wantRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureAppConnector(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
|
||||
if b.appConnector != nil {
|
||||
t.Fatal("unexpected app connector")
|
||||
}
|
||||
|
||||
b.EditPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
AppConnector: ipn.AppConnectorPrefs{
|
||||
Advertise: true,
|
||||
},
|
||||
},
|
||||
AppConnectorSet: true,
|
||||
})
|
||||
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
|
||||
if b.appConnector == nil {
|
||||
t.Fatal("expected app connector")
|
||||
}
|
||||
|
||||
appCfg := `{
|
||||
"name": "example",
|
||||
"domains": ["example.com"],
|
||||
"connectors": ["tag:example"]
|
||||
}`
|
||||
|
||||
b.netMap.SelfNode = (&tailcfg.Node{
|
||||
Name: "example.ts.net",
|
||||
Tags: []string{"tag:example"},
|
||||
CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{
|
||||
"tailscale.com/app-connectors": {tailcfg.RawMessage(appCfg)},
|
||||
}),
|
||||
}).View()
|
||||
|
||||
b.reconfigAppConnectorLocked(b.netMap, b.pm.prefs)
|
||||
|
||||
want := []string{"example.com"}
|
||||
if !slices.Equal(b.appConnector.Domains().AsSlice(), want) {
|
||||
t.Fatalf("got domains %v, want %v", b.appConnector.Domains(), want)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func resolversEqual(t *testing.T, a, b []*dnstype.Resolver) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
@@ -1176,3 +1286,50 @@ func routesEqual(t *testing.T, a, b map[dnsname.FQDN][]*dnstype.Resolver) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/dns/resolver"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netaddr"
|
||||
"tailscale.com/net/netutil"
|
||||
@@ -51,9 +50,14 @@ var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, stri
|
||||
// ("cleartext" HTTP/2) support to the peerAPI.
|
||||
var addH2C func(*http.Server)
|
||||
|
||||
// peerDNSQueryHandler is implemented by tsdns.Resolver.
|
||||
type peerDNSQueryHandler interface {
|
||||
HandlePeerDNSQuery(context.Context, []byte, netip.AddrPort, func(name string) bool) (res []byte, err error)
|
||||
}
|
||||
|
||||
type peerAPIServer struct {
|
||||
b *LocalBackend
|
||||
resolver *resolver.Resolver
|
||||
resolver peerDNSQueryHandler
|
||||
|
||||
taildrop *taildrop.Manager
|
||||
}
|
||||
@@ -861,9 +865,9 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
|
||||
return true
|
||||
}
|
||||
b := h.ps.b
|
||||
if !b.OfferingExitNode() {
|
||||
// If we're not an exit node, there's no point to
|
||||
// being a DNS server for somebody.
|
||||
if !b.OfferingExitNode() && !b.OfferingAppConnector() {
|
||||
// If we're not an exit node or app connector, there's
|
||||
// no point to being a DNS server for somebody.
|
||||
return false
|
||||
}
|
||||
if !h.remoteAddr.IsValid() {
|
||||
@@ -927,7 +931,7 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), arbitraryTimeout)
|
||||
defer cancel()
|
||||
res, err := h.ps.resolver.HandleExitNodeDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
|
||||
res, err := h.ps.resolver.HandlePeerDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
|
||||
if err != nil {
|
||||
h.logf("handleDNS fwd error: %v", err)
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -937,6 +941,13 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
return
|
||||
}
|
||||
// TODO(raggi): consider pushing the integration down into the resolver
|
||||
// instead to avoid re-parsing the DNS response for improved performance in
|
||||
// the future.
|
||||
if h.ps.b.OfferingAppConnector() {
|
||||
h.ps.b.ObserveDNSResponse(res)
|
||||
}
|
||||
|
||||
if pretty {
|
||||
// Non-standard response for interactive debugging.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -992,7 +1003,7 @@ func dnsQueryForName(name, typStr string) []byte {
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{
|
||||
OpCode: 0, // query
|
||||
RecursionDesired: true,
|
||||
ID: 0,
|
||||
ID: 1, // arbitrary, but 0 is rejected by some servers
|
||||
})
|
||||
if !strings.HasSuffix(name, ".") {
|
||||
name += "."
|
||||
|
||||
@@ -5,6 +5,7 @@ package ipnlocal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -14,11 +15,14 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go4.org/netipx"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
@@ -680,3 +684,63 @@ func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
||||
t.Errorf("unexpectedly IPv6 deny; wanted to be a DNS server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerAPIReplyToDNSQueriesAreObserved(t *testing.T) {
|
||||
var h peerAPIHandler
|
||||
h.remoteAddr = netip.MustParseAddrPort("100.150.151.152:12345")
|
||||
|
||||
rc := &routeCollector{}
|
||||
eng, _ := wgengine.NewFakeUserspaceEngine(logger.Discard, 0)
|
||||
pm := must.Get(newProfileManager(new(mem.Store), t.Logf))
|
||||
h.ps = &peerAPIServer{
|
||||
b: &LocalBackend{
|
||||
e: eng,
|
||||
pm: pm,
|
||||
store: pm.Store(),
|
||||
appConnector: appc.NewAppConnector(t.Logf, rc),
|
||||
},
|
||||
}
|
||||
h.ps.b.appConnector.UpdateDomains([]string{"example.com"})
|
||||
|
||||
h.ps.resolver = &fakeResolver{}
|
||||
f := filter.NewAllowAllForTest(logger.Discard)
|
||||
h.ps.b.setFilter(f)
|
||||
|
||||
if !h.ps.b.OfferingAppConnector() {
|
||||
t.Fatal("expecting to be offering app connector")
|
||||
}
|
||||
if !h.replyToDNSQueries() {
|
||||
t.Errorf("unexpectedly deny; wanted to be a DNS server")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.handleDNSQuery(w, httptest.NewRequest("GET", "/dns-query?q=true&t=example.com.", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("unexpected status code: %v", w.Code)
|
||||
}
|
||||
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
if !slices.Equal(rc.routes, wantRoutes) {
|
||||
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeResolver struct{}
|
||||
|
||||
func (f *fakeResolver) HandlePeerDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
|
||||
b.EnableCompression()
|
||||
b.StartAnswers()
|
||||
b.AResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName("example.com."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AResource{
|
||||
A: [4]byte{192, 0, 0, 8},
|
||||
},
|
||||
)
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
90
ipn/ipnlocal/web_client.go
Normal file
90
ipn/ipnlocal/web_client.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ios && !android
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/web"
|
||||
"tailscale.com/net/netutil"
|
||||
)
|
||||
|
||||
const webClientPort = web.ListenPort
|
||||
|
||||
// webClient holds state for the web interface for managing
|
||||
// this tailscale instance. The web interface is not used by
|
||||
// default, but initialized by calling LocalBackend.WebOrInit.
|
||||
type webClient struct {
|
||||
server *web.Server // or nil, initialized lazily
|
||||
|
||||
// lc optionally specifies a LocalClient to use to connect
|
||||
// to the localapi for this tailscaled instance.
|
||||
// If nil, a default is used.
|
||||
lc *tailscale.LocalClient
|
||||
}
|
||||
|
||||
// SetWebLocalClient sets the b.web.lc function.
|
||||
// If lc is provided as nil, b.web.lc is cleared out.
|
||||
func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.webClient.lc = lc
|
||||
}
|
||||
|
||||
// WebClientInit initializes the web interface for managing this
|
||||
// tailscaled instance.
|
||||
// If the web interface is already running, WebClientInit is a no-op.
|
||||
func (b *LocalBackend) WebClientInit() (err error) {
|
||||
if !b.ShouldRunWebClient() {
|
||||
return errors.New("web client not enabled for this device")
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.webClient.server != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.logf("WebClientInit: initializing web ui")
|
||||
if b.webClient.server, err = web.NewServer(web.ServerOpts{
|
||||
Mode: web.ManageServerMode,
|
||||
LocalClient: b.webClient.lc,
|
||||
Logf: b.logf,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("web.NewServer: %w", err)
|
||||
}
|
||||
|
||||
b.logf("WebClientInit: started web ui")
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebClientShutdown shuts down any running b.webClient servers and
|
||||
// clears out b.webClient state (besides the b.webClient.lc field,
|
||||
// which is left untouched because required for future web startups).
|
||||
// WebClientShutdown obtains the b.mu lock.
|
||||
func (b *LocalBackend) WebClientShutdown() {
|
||||
b.mu.Lock()
|
||||
server := b.webClient.server
|
||||
b.webClient.server = nil
|
||||
b.mu.Unlock() // release lock before shutdown
|
||||
if server != nil {
|
||||
server.Shutdown()
|
||||
b.logf("WebClientShutdown: shut down web ui")
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebClientConn serves web client requests.
|
||||
func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
|
||||
if err := b.WebClientInit(); err != nil {
|
||||
return err
|
||||
}
|
||||
s := http.Server{Handler: b.webClient.server}
|
||||
return s.Serve(netutil.NewOneConnListener(c, nil))
|
||||
}
|
||||
29
ipn/ipnlocal/web_client_stub.go
Normal file
29
ipn/ipnlocal/web_client_stub.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ios || android
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
const webClientPort = 5252
|
||||
|
||||
type webClient struct{}
|
||||
|
||||
func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {}
|
||||
|
||||
func (b *LocalBackend) WebClientInit() error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (b *LocalBackend) WebClientShutdown() {}
|
||||
|
||||
func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
@@ -367,8 +367,7 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool {
|
||||
// connIsLocalAdmin reports whether ci has administrative access to the local
|
||||
// machine, for whatever that means with respect to the current OS.
|
||||
//
|
||||
// This returns true only on Windows machines when the client user is a
|
||||
// member of the built-in Administrators group (but not necessarily elevated).
|
||||
// This returns true only on Windows machines when the client user is elevated.
|
||||
// This is useful because, on Windows, tailscaled itself always runs with
|
||||
// elevated rights: we want to avoid privilege escalation for certain mutative operations.
|
||||
func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
|
||||
@@ -381,12 +380,7 @@ func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
|
||||
}
|
||||
defer tok.Close()
|
||||
|
||||
isAdmin, err := tok.IsAdministrator()
|
||||
if err != nil {
|
||||
s.logf("ipnauth.WindowsToken.IsAdministrator() error: %v", err)
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
return tok.IsElevated()
|
||||
}
|
||||
|
||||
// addActiveHTTPRequest adds c to the server's list of active HTTP requests.
|
||||
|
||||
@@ -85,6 +85,7 @@ var handler = map[string]localAPIHandler{
|
||||
"derpmap": (*Handler).serveDERPMap,
|
||||
"dev-set-state-store": (*Handler).serveDevSetStateStore,
|
||||
"set-push-device-token": (*Handler).serveSetPushDeviceToken,
|
||||
"handle-push-message": (*Handler).serveHandlePushMessage,
|
||||
"dial": (*Handler).serveDial,
|
||||
"file-targets": (*Handler).serveFileTargets,
|
||||
"goroutines": (*Handler).serveGoroutines,
|
||||
@@ -1047,7 +1048,6 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not a flusher", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var mask ipn.NotifyWatchOpt
|
||||
if s := r.FormValue("mask"); s != "" {
|
||||
@@ -1058,6 +1058,16 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mask = ipn.NotifyWatchOpt(v)
|
||||
}
|
||||
// Users with only read access must request private key filtering. If they
|
||||
// don't filter out private keys, require write access.
|
||||
if (mask & ipn.NotifyNoPrivateKeys) == 0 {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "watch IPN bus access denied, must set ipn.NotifyNoPrivateKeys when not running as admin/root or operator", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ctx := r.Context()
|
||||
h.b.WatchNotifications(ctx, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) {
|
||||
js, err := json.Marshal(roNotify)
|
||||
@@ -1587,6 +1597,27 @@ func (h *Handler) serveSetPushDeviceToken(w http.ResponseWriter, r *http.Request
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) serveHandlePushMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "handle push message not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var pushMessageBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&pushMessageBody); err != nil {
|
||||
http.Error(w, "failed to decode JSON body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(bradfitz): do something with pushMessageBody
|
||||
h.logf("localapi: got push message: %v", logger.AsJSON(pushMessageBody))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
|
||||
@@ -1654,8 +1685,8 @@ func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitRead {
|
||||
http.Error(w, "lock status access denied", http.StatusForbidden)
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "lock sign access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != httpm.POST {
|
||||
|
||||
@@ -5,7 +5,10 @@ package localapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -17,8 +20,13 @@ import (
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/wgengine"
|
||||
)
|
||||
|
||||
func TestValidHost(t *testing.T) {
|
||||
@@ -212,3 +220,92 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeWatchIPNBus(t *testing.T) {
|
||||
tstest.Replace(t, &validLocalHostForTesting, true)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
permitRead, permitWrite bool
|
||||
mask ipn.NotifyWatchOpt // extra bits in addition to ipn.NotifyInitialState
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
desc: "no-permission",
|
||||
permitRead: false,
|
||||
permitWrite: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
desc: "read-initial-state",
|
||||
permitRead: true,
|
||||
permitWrite: false,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
desc: "read-initial-state-no-private-keys",
|
||||
permitRead: true,
|
||||
permitWrite: false,
|
||||
mask: ipn.NotifyNoPrivateKeys,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
desc: "read-initial-state-with-private-keys",
|
||||
permitRead: true,
|
||||
permitWrite: true,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
h := &Handler{
|
||||
PermitRead: tt.permitRead,
|
||||
PermitWrite: tt.permitWrite,
|
||||
b: newTestLocalBackend(t),
|
||||
}
|
||||
s := httptest.NewServer(h)
|
||||
defer s.Close()
|
||||
c := s.Client()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState|tt.mask), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
// Cancel the context so that localapi stops streaming IPN bus
|
||||
// updates.
|
||||
cancel()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != tt.wantStatus {
|
||||
t.Errorf("res.StatusCode=%d, want %d. body: %s", res.StatusCode, tt.wantStatus, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestLocalBackend(t testing.TB) *ipnlocal.LocalBackend {
|
||||
var logf logger.Logf = logger.Discard
|
||||
sys := new(tsd.System)
|
||||
store := new(mem.Store)
|
||||
sys.Set(store)
|
||||
eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFakeUserspaceEngine: %v", err)
|
||||
}
|
||||
t.Cleanup(eng.Close)
|
||||
sys.Set(eng)
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalBackend: %v", err)
|
||||
}
|
||||
return lb
|
||||
}
|
||||
|
||||
43
ipn/prefs.go
43
ipn/prefs.go
@@ -112,6 +112,11 @@ type Prefs struct {
|
||||
// policies as configured by the Tailnet's admin(s).
|
||||
RunSSH bool
|
||||
|
||||
// RunWebClient bool is whether this node should run a web client,
|
||||
// permitting access to peers according to the
|
||||
// policies as configured by the Tailnet's admin(s).
|
||||
RunWebClient bool
|
||||
|
||||
// WantRunning indicates whether networking should be active on
|
||||
// this node.
|
||||
WantRunning bool
|
||||
@@ -200,6 +205,10 @@ type Prefs struct {
|
||||
// AutoUpdatePrefs docs for more details.
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
|
||||
// AppConnector sets the app connector preferences for the node agent. See
|
||||
// AppConnectorPrefs docs for more details.
|
||||
AppConnector AppConnectorPrefs
|
||||
|
||||
// PostureChecking enables the collection of information used for device
|
||||
// posture checks.
|
||||
PostureChecking bool
|
||||
@@ -224,6 +233,13 @@ type AutoUpdatePrefs struct {
|
||||
Apply bool
|
||||
}
|
||||
|
||||
// AppConnectorPrefs are the app connector settings for the node agent.
|
||||
type AppConnectorPrefs struct {
|
||||
// Advertise specifies whether the app connector subsystem is advertising
|
||||
// this node as a connector.
|
||||
Advertise bool
|
||||
}
|
||||
|
||||
// MaskedPrefs is a Prefs with an associated bitmask of which fields are set.
|
||||
type MaskedPrefs struct {
|
||||
Prefs
|
||||
@@ -236,6 +252,7 @@ type MaskedPrefs struct {
|
||||
ExitNodeAllowLANAccessSet bool `json:",omitempty"`
|
||||
CorpDNSSet bool `json:",omitempty"`
|
||||
RunSSHSet bool `json:",omitempty"`
|
||||
RunWebClientSet bool `json:",omitempty"`
|
||||
WantRunningSet bool `json:",omitempty"`
|
||||
LoggedOutSet bool `json:",omitempty"`
|
||||
ShieldsUpSet bool `json:",omitempty"`
|
||||
@@ -250,6 +267,7 @@ type MaskedPrefs struct {
|
||||
OperatorUserSet bool `json:",omitempty"`
|
||||
ProfileNameSet bool `json:",omitempty"`
|
||||
AutoUpdateSet bool `json:",omitempty"`
|
||||
AppConnectorSet bool `json:",omitempty"`
|
||||
PostureCheckingSet bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -350,6 +368,9 @@ func (p *Prefs) pretty(goos string) string {
|
||||
if p.RunSSH {
|
||||
sb.WriteString("ssh=true ")
|
||||
}
|
||||
if p.RunWebClient {
|
||||
sb.WriteString("webclient=true ")
|
||||
}
|
||||
if p.LoggedOut {
|
||||
sb.WriteString("loggedout=true ")
|
||||
}
|
||||
@@ -389,6 +410,7 @@ func (p *Prefs) pretty(goos string) string {
|
||||
fmt.Fprintf(&sb, "op=%q ", p.OperatorUser)
|
||||
}
|
||||
sb.WriteString(p.AutoUpdate.Pretty())
|
||||
sb.WriteString(p.AppConnector.Pretty())
|
||||
if p.Persist != nil {
|
||||
sb.WriteString(p.Persist.Pretty())
|
||||
} else {
|
||||
@@ -431,6 +453,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
|
||||
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
|
||||
p.CorpDNS == p2.CorpDNS &&
|
||||
p.RunSSH == p2.RunSSH &&
|
||||
p.RunWebClient == p2.RunWebClient &&
|
||||
p.WantRunning == p2.WantRunning &&
|
||||
p.LoggedOut == p2.LoggedOut &&
|
||||
p.NotepadURLs == p2.NotepadURLs &&
|
||||
@@ -445,6 +468,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
|
||||
p.Persist.Equals(p2.Persist) &&
|
||||
p.ProfileName == p2.ProfileName &&
|
||||
p.AutoUpdate == p2.AutoUpdate &&
|
||||
p.AppConnector == p2.AppConnector &&
|
||||
p.PostureChecking == p2.PostureChecking
|
||||
}
|
||||
|
||||
@@ -458,6 +482,13 @@ func (au AutoUpdatePrefs) Pretty() string {
|
||||
return "update=off "
|
||||
}
|
||||
|
||||
func (ap AppConnectorPrefs) Pretty() string {
|
||||
if ap.Advertise {
|
||||
return "appconnector=advertise "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func compareIPNets(a, b []netip.Prefix) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
@@ -691,6 +722,18 @@ func (p *Prefs) ShouldSSHBeRunning() bool {
|
||||
return p.WantRunning && p.RunSSH
|
||||
}
|
||||
|
||||
// ShouldWebClientBeRunning reports whether the web client server should be running based on
|
||||
// the prefs.
|
||||
func (p PrefsView) ShouldWebClientBeRunning() bool {
|
||||
return p.Valid() && p.ж.ShouldWebClientBeRunning()
|
||||
}
|
||||
|
||||
// ShouldWebClientBeRunning reports whether the web client server should be running based on
|
||||
// the prefs.
|
||||
func (p *Prefs) ShouldWebClientBeRunning() bool {
|
||||
return p.WantRunning && p.RunWebClient
|
||||
}
|
||||
|
||||
// PrefsFromBytes deserializes Prefs from a JSON blob.
|
||||
func PrefsFromBytes(b []byte) (*Prefs, error) {
|
||||
p := NewPrefs()
|
||||
|
||||
@@ -43,6 +43,7 @@ func TestPrefsEqual(t *testing.T) {
|
||||
"ExitNodeAllowLANAccess",
|
||||
"CorpDNS",
|
||||
"RunSSH",
|
||||
"RunWebClient",
|
||||
"WantRunning",
|
||||
"LoggedOut",
|
||||
"ShieldsUp",
|
||||
@@ -57,6 +58,7 @@ func TestPrefsEqual(t *testing.T) {
|
||||
"OperatorUser",
|
||||
"ProfileName",
|
||||
"AutoUpdate",
|
||||
"AppConnector",
|
||||
"PostureChecking",
|
||||
"Persist",
|
||||
}
|
||||
@@ -305,6 +307,16 @@ func TestPrefsEqual(t *testing.T) {
|
||||
&Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
|
||||
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
&Prefs{AppConnector: AppConnectorPrefs{Advertise: true}},
|
||||
&Prefs{AppConnector: AppConnectorPrefs{Advertise: false}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
&Prefs{PostureChecking: true},
|
||||
&Prefs{PostureChecking: true},
|
||||
@@ -515,6 +527,24 @@ func TestPrefsPretty(t *testing.T) {
|
||||
"linux",
|
||||
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`,
|
||||
},
|
||||
{
|
||||
Prefs{
|
||||
AppConnector: AppConnectorPrefs{
|
||||
Advertise: true,
|
||||
},
|
||||
},
|
||||
"linux",
|
||||
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`,
|
||||
},
|
||||
{
|
||||
Prefs{
|
||||
AppConnector: AppConnectorPrefs{
|
||||
Advertise: false,
|
||||
},
|
||||
},
|
||||
"linux",
|
||||
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`,
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
got := tt.p.pretty(tt.os)
|
||||
|
||||
@@ -64,7 +64,7 @@ var (
|
||||
|
||||
var rootServersV4 = []netip.Addr{
|
||||
netip.MustParseAddr("198.41.0.4"), // a.root-servers.net
|
||||
netip.MustParseAddr("199.9.14.201"), // b.root-servers.net
|
||||
netip.MustParseAddr("170.247.170.2"), // b.root-servers.net
|
||||
netip.MustParseAddr("192.33.4.12"), // c.root-servers.net
|
||||
netip.MustParseAddr("199.7.91.13"), // d.root-servers.net
|
||||
netip.MustParseAddr("192.203.230.10"), // e.root-servers.net
|
||||
@@ -80,7 +80,7 @@ var rootServersV4 = []netip.Addr{
|
||||
|
||||
var rootServersV6 = []netip.Addr{
|
||||
netip.MustParseAddr("2001:503:ba3e::2:30"), // a.root-servers.net
|
||||
netip.MustParseAddr("2001:500:200::b"), // b.root-servers.net
|
||||
netip.MustParseAddr("2801:1b8:10::b"), // b.root-servers.net
|
||||
netip.MustParseAddr("2001:500:2::c"), // c.root-servers.net
|
||||
netip.MustParseAddr("2001:500:2d::d"), // d.root-servers.net
|
||||
netip.MustParseAddr("2001:500:a8::e"), // e.root-servers.net
|
||||
|
||||
@@ -314,9 +314,9 @@ func parseExitNodeQuery(q []byte) *response {
|
||||
return p.response()
|
||||
}
|
||||
|
||||
// HandleExitNodeDNSQuery handles a DNS query that arrived from a peer
|
||||
// via the peerapi's DoH server. This is only used when the local
|
||||
// node is being an exit node.
|
||||
// HandlePeerDNSQuery handles a DNS query that arrived from a peer
|
||||
// via the peerapi's DoH server. This is used when the local
|
||||
// node is being an exit node or an app connector.
|
||||
//
|
||||
// The provided allowName callback is whether a DNS query for a name
|
||||
// (as found by parsing q) is allowed.
|
||||
@@ -325,7 +325,7 @@ func parseExitNodeQuery(q []byte) *response {
|
||||
// still result in a response DNS packet (saying there's a failure)
|
||||
// and a nil error.
|
||||
// TODO: figure out if we even need an error result.
|
||||
func (r *Resolver) HandleExitNodeDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
|
||||
func (r *Resolver) HandlePeerDNSQuery(ctx context.Context, q []byte, from netip.AddrPort, allowName func(name string) bool) (res []byte, err error) {
|
||||
metricDNSExitProxyQuery.Add(1)
|
||||
ch := make(chan packet, 1)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// TODO(bradfitz): update this code to use netaddr more
|
||||
|
||||
// Package dnscache contains a minimal DNS cache that makes a bunch of
|
||||
// assumptions that are only valid for us. Not recommended for general use.
|
||||
package dnscache
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
) {
|
||||
src = ./.;
|
||||
}).shellNix
|
||||
# nix-direnv cache busting line: sha256-WGZkpffwe4I8FewdBHXGaLbKQP/kHr7UF2lCXBTcNb4=
|
||||
# nix-direnv cache busting line: sha256-ynBPVRKWPcoEbbZ/atvO6VdjHVwhnWcugSD3RGJA34E=
|
||||
|
||||
@@ -624,11 +624,12 @@ func (h *Hostinfo) CheckRequestTags() error {
|
||||
type ServiceProto string
|
||||
|
||||
const (
|
||||
TCP = ServiceProto("tcp")
|
||||
UDP = ServiceProto("udp")
|
||||
PeerAPI4 = ServiceProto("peerapi4")
|
||||
PeerAPI6 = ServiceProto("peerapi6")
|
||||
PeerAPIDNS = ServiceProto("peerapi-dns-proxy")
|
||||
TCP = ServiceProto("tcp")
|
||||
UDP = ServiceProto("udp")
|
||||
PeerAPI4 = ServiceProto("peerapi4")
|
||||
PeerAPI6 = ServiceProto("peerapi6")
|
||||
PeerAPIDNS = ServiceProto("peerapi-dns-proxy")
|
||||
AppConnector = ServiceProto("app-connector")
|
||||
)
|
||||
|
||||
// Service represents a service running on a node.
|
||||
@@ -645,10 +646,13 @@ type Service struct {
|
||||
// * "peerapi6": peerapi is available on IPv6; Port is the
|
||||
// port number that the peerapi is running on the
|
||||
// node's Tailscale IPv6 address.
|
||||
// * "peerapi-dns": the local peerapi service supports
|
||||
// * "peerapi-dns-proxy": the local peerapi service supports
|
||||
// being a DNS proxy (when the node is an exit
|
||||
// node). For this service, the Port number is really
|
||||
// the version number of the service.
|
||||
// * "app-connector": the local app-connector service is
|
||||
// available. For this service, the Port number is
|
||||
// really the version number of the service.
|
||||
Proto ServiceProto
|
||||
|
||||
// Port is the port number.
|
||||
@@ -2040,6 +2044,7 @@ const (
|
||||
CapabilityDataPlaneAuditLogs NodeCapability = "https://tailscale.com/cap/data-plane-audit-logs" // feature enabled
|
||||
CapabilityDebug NodeCapability = "https://tailscale.com/cap/debug" // exposes debug endpoints over the PeerAPI
|
||||
CapabilityHTTPS NodeCapability = "https" // https cert provisioning enabled on tailnet
|
||||
CapabilityPreviewWebClient NodeCapability = "preview-webclient" // allows starting web client in tailscaled
|
||||
|
||||
// CapabilityBindToInterfaceByRoute changes how Darwin nodes create
|
||||
// sockets (in the net/netns package). See that package for more
|
||||
|
||||
69
tool/helm
Executable file
69
tool/helm
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# installs $(cat ./helm.rev) version of helm as $HOME/.cache/tailscale-helm
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
(
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
repo_root="${BASH_SOURCE%/*}/../"
|
||||
cd "$repo_root"
|
||||
|
||||
cachedir="$HOME/.cache/tailscale-helm"
|
||||
tarball="${cachedir}.tar.gz"
|
||||
|
||||
read -r want_rev < "$(dirname "$0")/helm.rev"
|
||||
|
||||
got_rev=""
|
||||
if [[ -x "${cachedir}/helm" ]]; then
|
||||
got_rev=$("${cachedir}/helm" version --short)
|
||||
got_rev="${got_rev#v}" # trim the leading 'v'
|
||||
got_rev="${got_rev%+*}" # trim the trailing '+" followed by a commit SHA'
|
||||
|
||||
|
||||
fi
|
||||
|
||||
if [[ "$want_rev" != "$got_rev" ]]; then
|
||||
rm -rf "$cachedir" "$tarball"
|
||||
if [[ -n "${IN_NIX_SHELL:-}" ]]; then
|
||||
nix_helm="$(which -a helm | grep /nix/store | head -1)"
|
||||
nix_helm="${nix_helm%/helm}"
|
||||
nix_helm_rev="${nix_helm##*-}"
|
||||
if [[ "$nix_helm_rev" != "$want_rev" ]]; then
|
||||
echo "Wrong helm version in Nix, got $nix_helm_rev want $want_rev" >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -sf "$nix_helm" "$cachedir"
|
||||
else
|
||||
# works for linux and darwin
|
||||
# https://github.com/helm/helm/releases
|
||||
OS=$(uname -s | tr A-Z a-z)
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
ARCH="amd64"
|
||||
fi
|
||||
if [ "$ARCH" = "aarch64" ]; then
|
||||
ARCH="arm64"
|
||||
fi
|
||||
mkdir -p "$cachedir"
|
||||
# When running on GitHub in CI, the below curl sometimes fails with
|
||||
# INTERNAL_ERROR after finishing the download. The most common cause
|
||||
# of INTERNAL_ERROR is glitches in intermediate hosts handling of
|
||||
# HTTP/2 forwarding, so forcing HTTP 1.1 often fixes the issue. See
|
||||
# https://github.com/tailscale/tailscale/issues/8988
|
||||
curl -f -L --http1.1 -o "$tarball" -sSL "https://get.helm.sh/helm-v${want_rev}-${OS}-${ARCH}.tar.gz"
|
||||
(cd "$cachedir" && tar --strip-components=1 -xf "$tarball")
|
||||
rm -f "$tarball"
|
||||
fi
|
||||
fi
|
||||
)
|
||||
|
||||
export PATH="$HOME/.cache/tailscale-helm:$PATH"
|
||||
exec "$HOME/.cache/tailscale-helm/helm" "$@"
|
||||
1
tool/helm.rev
Normal file
1
tool/helm.rev
Normal file
@@ -0,0 +1 @@
|
||||
3.13.1
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", "localhost:8060", "address of Tailscale web client")
|
||||
devMode = flag.Bool("dev", false, "run web client in dev mode")
|
||||
addr = flag.String("addr", "localhost:8060", "address of Tailscale web client")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -30,11 +29,14 @@ func main() {
|
||||
}
|
||||
|
||||
// Serve the Tailscale web client.
|
||||
ws, cleanup := web.NewServer(web.ServerOpts{
|
||||
DevMode: *devMode,
|
||||
ws, err := web.NewServer(web.ServerOpts{
|
||||
Mode: web.LegacyServerMode,
|
||||
LocalClient: lc,
|
||||
})
|
||||
defer cleanup()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ws.Shutdown()
|
||||
log.Printf("Serving Tailscale web client on http://%s", *addr)
|
||||
if err := http.ListenAndServe(*addr, ws); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
|
||||
@@ -533,6 +533,7 @@ func (s *Server) start() (reterr error) {
|
||||
sys.Tun.Get().Start()
|
||||
sys.Set(ns)
|
||||
ns.ProcessLocalIPs = true
|
||||
ns.ProcessSubnets = true
|
||||
ns.GetTCPHandlerForFlow = s.getTCPHandlerForFlow
|
||||
ns.GetUDPHandlerForFlow = s.getUDPHandlerForFlow
|
||||
s.netstack = ns
|
||||
@@ -731,19 +732,39 @@ func networkForFamily(netBase string, is6 bool) string {
|
||||
// - ("tcp", "", port)
|
||||
//
|
||||
// The netBase is "tcp" or "udp" (without any '4' or '6' suffix).
|
||||
//
|
||||
// Listeners which do not specify an IP address will match for traffic
|
||||
// for the local node (that is, a destination address of the IPv4 or
|
||||
// IPv6 address of this node) only. To listen for traffic on other addresses
|
||||
// such as those routed inbound via subnet routes, explicitly specify
|
||||
// the listening address or use RegisterFallbackTCPHandler.
|
||||
func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort, funnel bool) (_ *listener, ok bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, a := range [2]netip.Addr{0: dst.Addr()} {
|
||||
|
||||
// Search for a listener with the specified IP
|
||||
for _, net := range [2]string{
|
||||
networkForFamily(netBase, dst.Addr().Is6()),
|
||||
netBase,
|
||||
} {
|
||||
if ln, ok := s.listeners[listenKey{net, dst.Addr(), dst.Port(), funnel}]; ok {
|
||||
return ln, true
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a listener without an IP if the destination was
|
||||
// one of the native IPs of the node.
|
||||
if ip4, ip6 := s.TailscaleIPs(); dst.Addr() == ip4 || dst.Addr() == ip6 {
|
||||
for _, net := range [2]string{
|
||||
networkForFamily(netBase, dst.Addr().Is6()),
|
||||
netBase,
|
||||
} {
|
||||
if ln, ok := s.listeners[listenKey{net, a, dst.Port(), funnel}]; ok {
|
||||
if ln, ok := s.listeners[listenKey{net, netip.Addr{}, dst.Port(), funnel}]; ok {
|
||||
return ln, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -853,6 +874,12 @@ func (s *Server) APIClient() (*tailscale.Client, error) {
|
||||
|
||||
// Listen announces only on the Tailscale network.
|
||||
// It will start the server if it has not been started yet.
|
||||
//
|
||||
// Listeners which do not specify an IP address will match for traffic
|
||||
// for the local node (that is, a destination address of the IPv4 or
|
||||
// IPv6 address of this node) only. To listen for traffic on other addresses
|
||||
// such as those routed inbound via subnet routes, explicitly specify
|
||||
// the listening address or use RegisterFallbackTCPHandler.
|
||||
func (s *Server) Listen(network, addr string) (net.Listener, error) {
|
||||
return s.listen(network, addr, listenOnTailnet)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstest/integration"
|
||||
"tailscale.com/tstest/integration/testcontrol"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
@@ -95,7 +96,7 @@ func TestListenerPort(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) (controlURL string) {
|
||||
func startControl(t *testing.T) (controlURL string, control *testcontrol.Server) {
|
||||
// Corp#4520: don't use netns for tests.
|
||||
netns.SetEnabled(false)
|
||||
t.Cleanup(func() {
|
||||
@@ -107,7 +108,7 @@ func startControl(t *testing.T) (controlURL string) {
|
||||
derpLogf = t.Logf
|
||||
}
|
||||
derpMap := integration.RunDERPAndSTUN(t, derpLogf, "127.0.0.1")
|
||||
control := &testcontrol.Server{
|
||||
control = &testcontrol.Server{
|
||||
DERPMap: derpMap,
|
||||
DNSConfig: &tailcfg.DNSConfig{
|
||||
Proxied: true,
|
||||
@@ -119,7 +120,7 @@ func startControl(t *testing.T) (controlURL string) {
|
||||
t.Cleanup(control.HTTPTestServer.Close)
|
||||
controlURL = control.HTTPTestServer.URL
|
||||
t.Logf("testcontrol listening on %s", controlURL)
|
||||
return controlURL
|
||||
return controlURL, control
|
||||
}
|
||||
|
||||
type testCertIssuer struct {
|
||||
@@ -200,7 +201,7 @@ func (tci *testCertIssuer) Pool() *x509.CertPool {
|
||||
|
||||
var testCertRoot = newCertIssuer()
|
||||
|
||||
func startServer(t *testing.T, ctx context.Context, controlURL, hostname string) (*Server, netip.Addr) {
|
||||
func startServer(t *testing.T, ctx context.Context, controlURL, hostname string) (*Server, netip.Addr, key.NodePublic) {
|
||||
t.Helper()
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), hostname)
|
||||
@@ -222,7 +223,7 @@ func startServer(t *testing.T, ctx context.Context, controlURL, hostname string)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, status.TailscaleIPs[0]
|
||||
return s, status.TailscaleIPs[0], status.Self.PublicKey
|
||||
}
|
||||
|
||||
func TestConn(t *testing.T) {
|
||||
@@ -230,9 +231,17 @@ func TestConn(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, s1ip := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _ := startServer(t, ctx, controlURL, "s2")
|
||||
controlURL, c := startControl(t)
|
||||
s1, s1ip, s1PubKey := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _, _ := startServer(t, ctx, controlURL, "s2")
|
||||
|
||||
s1.lb.EditPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
AdvertiseRoutes: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||
},
|
||||
AdvertiseRoutesSet: true,
|
||||
})
|
||||
c.SetSubnetRoutes(s1PubKey, []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")})
|
||||
|
||||
lc2, err := s2.LocalClient()
|
||||
if err != nil {
|
||||
@@ -281,6 +290,15 @@ func TestConn(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("unexpected success; should have seen a connection refused error")
|
||||
}
|
||||
|
||||
// s1 is a subnet router for TEST-NET-1 (192.0.2.0/24). Lets dial to that
|
||||
// subnet from s2 to ensure a listener without an IP address (i.e. ":8081")
|
||||
// only matches destination IPs corresponding to the node's IP, and not
|
||||
// to any random IP a subnet is routing.
|
||||
_, err = s2.Dial(ctx, "tcp", fmt.Sprintf("%s:8081", "192.0.2.1"))
|
||||
if err == nil {
|
||||
t.Fatalf("unexpected success; should have seen a connection refused error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackLocalAPI(t *testing.T) {
|
||||
@@ -289,8 +307,8 @@ func TestLoopbackLocalAPI(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, _ := startServer(t, ctx, controlURL, "s1")
|
||||
controlURL, _ := startControl(t)
|
||||
s1, _, _ := startServer(t, ctx, controlURL, "s1")
|
||||
|
||||
addr, proxyCred, localAPICred, err := s1.Loopback()
|
||||
if err != nil {
|
||||
@@ -363,9 +381,9 @@ func TestLoopbackSOCKS5(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, s1ip := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _ := startServer(t, ctx, controlURL, "s2")
|
||||
controlURL, _ := startControl(t)
|
||||
s1, s1ip, _ := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _, _ := startServer(t, ctx, controlURL, "s2")
|
||||
|
||||
addr, proxyCred, _, err := s2.Loopback()
|
||||
if err != nil {
|
||||
@@ -410,7 +428,7 @@ func TestLoopbackSOCKS5(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTailscaleIPs(t *testing.T) {
|
||||
controlURL := startControl(t)
|
||||
controlURL, _ := startControl(t)
|
||||
|
||||
tmp := t.TempDir()
|
||||
tmps1 := filepath.Join(tmp, "s1")
|
||||
@@ -455,8 +473,8 @@ func TestListenerCleanup(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, _ := startServer(t, ctx, controlURL, "s1")
|
||||
controlURL, _ := startControl(t)
|
||||
s1, _, _ := startServer(t, ctx, controlURL, "s1")
|
||||
|
||||
ln, err := s1.Listen("tcp", ":8081")
|
||||
if err != nil {
|
||||
@@ -475,7 +493,7 @@ func TestListenerCleanup(t *testing.T) {
|
||||
// tests https://github.com/tailscale/tailscale/issues/6973 -- that we can start a tsnet server,
|
||||
// stop it, and restart it, even on Windows.
|
||||
func TestStartStopStartGetsSameIP(t *testing.T) {
|
||||
controlURL := startControl(t)
|
||||
controlURL, _ := startControl(t)
|
||||
|
||||
tmp := t.TempDir()
|
||||
tmps1 := filepath.Join(tmp, "s1")
|
||||
@@ -527,9 +545,9 @@ func TestFunnel(t *testing.T) {
|
||||
ctx, dialCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer dialCancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, _ := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _ := startServer(t, ctx, controlURL, "s2")
|
||||
controlURL, _ := startControl(t)
|
||||
s1, _, _ := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _, _ := startServer(t, ctx, controlURL, "s2")
|
||||
|
||||
ln := must.Get(s1.ListenFunnel("tcp", ":443"))
|
||||
defer ln.Close()
|
||||
@@ -637,9 +655,9 @@ func TestFallbackTCPHandler(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, s1ip := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _ := startServer(t, ctx, controlURL, "s2")
|
||||
controlURL, _ := startControl(t)
|
||||
s1, s1ip, _ := startServer(t, ctx, controlURL, "s1")
|
||||
s2, _, _ := startServer(t, ctx, controlURL, "s2")
|
||||
|
||||
lc2, err := s2.LocalClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -73,7 +73,7 @@ func ImportAliasCheck(t testing.TB, relDir string) {
|
||||
t.Logf("ignoring error: %v, %s", err, matches)
|
||||
return
|
||||
}
|
||||
badRx := regexp.MustCompile(`^([^:]+:\d+):\s+"golang.org/x/exp/(slices|maps)"`)
|
||||
badRx := regexp.MustCompile(`^([^:]+:\d+):\s+"golang\.org/x/exp/(slices|maps)"`)
|
||||
if s := strings.TrimSpace(string(matches)); s != "" {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if m := badRx.FindStringSubmatch(line); m != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// transitive deps when we run "go install tailscaled" in a child
|
||||
// process and can cache a prior success when a dependency changes.
|
||||
_ "tailscale.com/chirp"
|
||||
_ "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/cmd/tailscaled/childproc"
|
||||
_ "tailscale.com/control/controlclient"
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// transitive deps when we run "go install tailscaled" in a child
|
||||
// process and can cache a prior success when a dependency changes.
|
||||
_ "tailscale.com/chirp"
|
||||
_ "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/cmd/tailscaled/childproc"
|
||||
_ "tailscale.com/control/controlclient"
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// transitive deps when we run "go install tailscaled" in a child
|
||||
// process and can cache a prior success when a dependency changes.
|
||||
_ "tailscale.com/chirp"
|
||||
_ "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/cmd/tailscaled/childproc"
|
||||
_ "tailscale.com/control/controlclient"
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// transitive deps when we run "go install tailscaled" in a child
|
||||
// process and can cache a prior success when a dependency changes.
|
||||
_ "tailscale.com/chirp"
|
||||
_ "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/cmd/tailscaled/childproc"
|
||||
_ "tailscale.com/control/controlclient"
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
_ "golang.org/x/sys/windows/svc/mgr"
|
||||
_ "golang.zx2c4.com/wintun"
|
||||
_ "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
_ "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/cmd/tailscaled/childproc"
|
||||
_ "tailscale.com/control/controlclient"
|
||||
_ "tailscale.com/derp/derphttp"
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/util/set"
|
||||
@@ -64,12 +65,19 @@ type Server struct {
|
||||
pubKey key.MachinePublic
|
||||
privKey key.ControlPrivate // not strictly needed vs. MachinePrivate, but handy to test type interactions.
|
||||
|
||||
// nodeSubnetRoutes is a list of subnet routes that are served
|
||||
// by the specified node.
|
||||
nodeSubnetRoutes map[key.NodePublic][]netip.Prefix
|
||||
|
||||
// masquerades is the set of masquerades that should be applied to
|
||||
// MapResponses sent to clients. It is keyed by the requesting nodes
|
||||
// public key, and then the peer node's public key. The value is the
|
||||
// masquerade address to use for that peer.
|
||||
masquerades map[key.NodePublic]map[key.NodePublic]netip.Addr // node => peer => SelfNodeV{4,6}MasqAddrForThisPeer IP
|
||||
|
||||
// nodeCapMaps overrides the capability map sent down to a client.
|
||||
nodeCapMaps map[key.NodePublic]tailcfg.NodeCapMap
|
||||
|
||||
// suppressAutoMapResponses is the set of nodes that should not be sent
|
||||
// automatic map responses from serveMap. (They should only get manually sent ones)
|
||||
suppressAutoMapResponses set.Set[key.NodePublic]
|
||||
@@ -328,6 +336,13 @@ func (s *Server) serveMachine(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSubnetRoutes sets the list of subnet routes which a node is routing.
|
||||
func (s *Server) SetSubnetRoutes(nodeKey key.NodePublic, routes []netip.Prefix) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
mak.Set(&s.nodeSubnetRoutes, nodeKey, routes)
|
||||
}
|
||||
|
||||
// MasqueradePair is a pair of nodes and the IP address that the
|
||||
// Node masquerades as for the Peer.
|
||||
//
|
||||
@@ -357,6 +372,14 @@ func (s *Server) SetMasqueradeAddresses(pairs []MasqueradePair) {
|
||||
s.updateLocked("SetMasqueradeAddresses", s.nodeIDsLocked(0))
|
||||
}
|
||||
|
||||
// SetNodeCapMap overrides the capability map the specified client receives.
|
||||
func (s *Server) SetNodeCapMap(nodeKey key.NodePublic, capMap tailcfg.NodeCapMap) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
mak.Set(&s.nodeCapMaps, nodeKey, capMap)
|
||||
s.updateLocked("SetNodeCapMap", s.nodeIDsLocked(0))
|
||||
}
|
||||
|
||||
// nodeIDsLocked returns the node IDs of all nodes in the server, except
|
||||
// for the node with the given ID.
|
||||
func (s *Server) nodeIDsLocked(except tailcfg.NodeID) []tailcfg.NodeID {
|
||||
@@ -869,6 +892,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
||||
// node key rotated away (once test server supports that)
|
||||
return nil, nil
|
||||
}
|
||||
node.CapMap = s.nodeCapMaps[nk]
|
||||
node.Capabilities = append(node.Capabilities, tailcfg.NodeAttrDisableUPnP)
|
||||
|
||||
user, _ := s.getUser(nk)
|
||||
@@ -908,6 +932,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
||||
|
||||
s.mu.Lock()
|
||||
peerAddress := s.masquerades[p.Key][node.Key]
|
||||
routes := s.nodeSubnetRoutes[p.Key]
|
||||
s.mu.Unlock()
|
||||
if peerAddress.IsValid() {
|
||||
if peerAddress.Is6() {
|
||||
@@ -918,6 +943,10 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
||||
p.AllowedIPs[0] = netip.PrefixFrom(peerAddress, peerAddress.BitLen())
|
||||
}
|
||||
}
|
||||
if len(routes) > 0 {
|
||||
p.PrimaryRoutes = routes
|
||||
p.AllowedIPs = append(p.AllowedIPs, routes...)
|
||||
}
|
||||
res.Peers = append(res.Peers, p)
|
||||
}
|
||||
|
||||
@@ -939,11 +968,12 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
|
||||
v4Prefix,
|
||||
v6Prefix,
|
||||
}
|
||||
res.Node.AllowedIPs = res.Node.Addresses
|
||||
|
||||
// Consume a PingRequest while protected by mutex if it exists
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res.Node.AllowedIPs = append(res.Node.Addresses, s.nodeSubnetRoutes[nk]...)
|
||||
|
||||
// Consume a PingRequest while protected by mutex if it exists
|
||||
switch m := s.msgToSend[nk].(type) {
|
||||
case *tailcfg.PingRequest:
|
||||
res.PingRequest = m
|
||||
|
||||
@@ -57,3 +57,16 @@ type SNIProxyConfig struct {
|
||||
// the domain starts with a `.` that means any subdomain of the suffix.
|
||||
AllowedDomains []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// AppConnectorAttr describes a set of domains
|
||||
// serviced by specified app connectors.
|
||||
type AppConnectorAttr struct {
|
||||
// Name is the name of this collection of domains.
|
||||
Name string `json:"name,omitempty"`
|
||||
// Domains enumerates the domains serviced by the specified app connectors.
|
||||
// Domains can be of the form: example.com, or *.example.com.
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
// Connectors enumerates the app connectors which service these domains.
|
||||
// These can be any target type supported by Tailscale's ACL language.
|
||||
Connectors []string `json:"connectors,omitempty"`
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ type Resolver struct {
|
||||
// as of 2022-09-08 only used for certain well-known resolvers
|
||||
// (see the publicdns package) for which the IP addresses to dial DoH are
|
||||
// known ahead of time, so bootstrap DNS resolution is not required.
|
||||
// - "http://node-address:port/path" for DNS over HTTP over WireGuard. This
|
||||
// is implemented in the PeerAPI for exit nodes and app connectors.
|
||||
// - [TODO] "tls://resolver.com" for DNS over TCP+TLS
|
||||
Addr string `json:",omitempty"`
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ package groupmember
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// IsMemberOfGroup reports whether the provided user is a member of
|
||||
@@ -16,18 +17,13 @@ func IsMemberOfGroup(group, userName string) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
ugids, err := u.GroupIds()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
g, err := user.LookupGroup(group)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, ugid := range ugids {
|
||||
if g.Gid == ugid {
|
||||
return true, nil
|
||||
}
|
||||
ugids, err := u.GroupIds()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
return slices.Contains(ugids, g.Gid), nil
|
||||
}
|
||||
|
||||
@@ -533,7 +533,9 @@ func TestAddAndDelNetfilterChains(t *testing.T) {
|
||||
checkChains(t, conn, nftables.TableFamilyIPv6, 0)
|
||||
|
||||
runner := newFakeNftablesRunner(t, conn)
|
||||
runner.AddChains()
|
||||
if err := runner.AddChains(); err != nil {
|
||||
t.Fatalf("runner.AddChains() failed: %v", err)
|
||||
}
|
||||
|
||||
tables, err := conn.ListTables()
|
||||
if err != nil {
|
||||
@@ -664,9 +666,13 @@ func TestNFTAddAndDelNetfilterBase(t *testing.T) {
|
||||
conn := newSysConn(t)
|
||||
|
||||
runner := newFakeNftablesRunner(t, conn)
|
||||
runner.AddChains()
|
||||
if err := runner.AddChains(); err != nil {
|
||||
t.Fatalf("AddChains() failed: %v", err)
|
||||
}
|
||||
defer runner.DelChains()
|
||||
runner.AddBase("testTunn")
|
||||
if err := runner.AddBase("testTunn"); err != nil {
|
||||
t.Fatalf("AddBase() failed: %v", err)
|
||||
}
|
||||
|
||||
// check number of rules in each IPv4 TS chain
|
||||
inputV4, forwardV4, postroutingV4, err := getTsChains(conn, nftables.TableFamilyIPv4)
|
||||
@@ -754,7 +760,9 @@ func TestNFTAddAndDelLoopbackRule(t *testing.T) {
|
||||
conn := newSysConn(t)
|
||||
|
||||
runner := newFakeNftablesRunner(t, conn)
|
||||
runner.AddChains()
|
||||
if err := runner.AddChains(); err != nil {
|
||||
t.Fatalf("AddChains() failed: %v", err)
|
||||
}
|
||||
defer runner.DelChains()
|
||||
|
||||
inputV4, _, _, err := getTsChains(conn, nftables.TableFamilyIPv4)
|
||||
@@ -810,9 +818,13 @@ func TestNFTAddAndDelLoopbackRule(t *testing.T) {
|
||||
func TestNFTAddAndDelHookRule(t *testing.T) {
|
||||
conn := newSysConn(t)
|
||||
runner := newFakeNftablesRunner(t, conn)
|
||||
runner.AddChains()
|
||||
if err := runner.AddChains(); err != nil {
|
||||
t.Fatalf("AddChains() failed: %v", err)
|
||||
}
|
||||
defer runner.DelChains()
|
||||
runner.AddHooks()
|
||||
if err := runner.AddHooks(); err != nil {
|
||||
t.Fatalf("AddHooks() failed: %v", err)
|
||||
}
|
||||
|
||||
forwardChain, err := getChainFromTable(conn, runner.nft4.Filter, "FORWARD")
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// Package set contains set types.
|
||||
package set
|
||||
|
||||
import (
|
||||
"maps"
|
||||
)
|
||||
|
||||
// Set is a set of T.
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
@@ -14,16 +18,28 @@ func SetOf[T comparable](slice []T) Set[T] {
|
||||
return s
|
||||
}
|
||||
|
||||
// Add adds e to the set.
|
||||
// Clone returns a new set cloned from the elements in s.
|
||||
func (s Set[T]) Clone() Set[T] {
|
||||
return maps.Clone(s)
|
||||
}
|
||||
|
||||
// Add adds e to s.
|
||||
func (s Set[T]) Add(e T) { s[e] = struct{}{} }
|
||||
|
||||
// AddSlice adds each element of es to the set.
|
||||
// AddSlice adds each element of es to s.
|
||||
func (s Set[T]) AddSlice(es []T) {
|
||||
for _, e := range es {
|
||||
s.Add(e)
|
||||
}
|
||||
}
|
||||
|
||||
// AddSet adds each element of es to s.
|
||||
func (s Set[T]) AddSet(es Set[T]) {
|
||||
for e := range es {
|
||||
s.Add(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Slice returns the elements of the set as a slice. The elements will not be
|
||||
// in any particular order.
|
||||
func (s Set[T]) Slice() []T {
|
||||
@@ -45,3 +61,8 @@ func (s Set[T]) Contains(e T) bool {
|
||||
|
||||
// Len reports the number of items in s.
|
||||
func (s Set[T]) Len() int { return len(s) }
|
||||
|
||||
// Equal reports whether s is equal to other.
|
||||
func (s Set[T]) Equal(other Set[T]) bool {
|
||||
return maps.Equal(s, other)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user