This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -15,7 +15,7 @@ func init() {
func breakTCPConnsDarwin() error {
var matched int
for fd := 0; fd < 1000; fd++ {
for fd := range 1000 {
_, err := unix.GetsockoptTCPConnectionInfo(fd, unix.IPPROTO_TCP, unix.TCP_CONNECTION_INFO)
if err == nil {
matched++
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -15,7 +15,7 @@ func init() {
func breakTCPConnsLinux() error {
var matched int
for fd := 0; fd < 1000; fd++ {
for fd := range 1000 {
_, err := unix.GetsockoptTCPInfo(fd, unix.IPPROTO_TCP, unix.TCP_INFO)
if err == nil {
matched++
-161
View File
@@ -1,161 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"time"
"tailscale.com/ipn"
"tailscale.com/tstime"
)
type rateLimitingBusSender struct {
fn func(*ipn.Notify) (keepGoing bool)
lastFlush time.Time // last call to fn, or zero value if none
interval time.Duration // 0 to flush immediately; non-zero to rate limit sends
clock tstime.DefaultClock // non-nil for testing
didSendTestHook func() // non-nil for testing
// pending, if non-nil, is the pending notification that we
// haven't sent yet. We own this memory to mutate.
pending *ipn.Notify
// flushTimer is non-nil if the timer is armed.
flushTimer tstime.TimerController // effectively a *time.Timer
flushTimerC <-chan time.Time // ... said ~Timer's C chan
}
func (s *rateLimitingBusSender) close() {
if s.flushTimer != nil {
s.flushTimer.Stop()
}
}
func (s *rateLimitingBusSender) flushChan() <-chan time.Time {
return s.flushTimerC
}
func (s *rateLimitingBusSender) flush() (keepGoing bool) {
if n := s.pending; n != nil {
s.pending = nil
return s.flushNotify(n)
}
return true
}
func (s *rateLimitingBusSender) flushNotify(n *ipn.Notify) (keepGoing bool) {
s.lastFlush = s.clock.Now()
return s.fn(n)
}
// send conditionally sends n to the underlying fn, possibly rate
// limiting it, depending on whether s.interval is set, and whether
// n is a notable notification that the client (typically a GUI) would
// want to act on (render) immediately.
//
// It returns whether the caller should keep looping.
//
// The passed-in memory 'n' is owned by the caller and should
// not be mutated.
func (s *rateLimitingBusSender) send(n *ipn.Notify) (keepGoing bool) {
if s.interval <= 0 {
// No rate limiting case.
return s.fn(n)
}
if isNotableNotify(n) {
// Notable notifications are always sent immediately.
// But first send any boring one that was pending.
// TODO(bradfitz): there might be a boring one pending
// with a NetMap or Engine field that is redundant
// with the new one (n) with NetMap or Engine populated.
// We should clear the pending one's NetMap/Engine in
// that case. Or really, merge the two, but mergeBoringNotifies
// only handles the case of both sides being boring.
// So for now, flush both.
if !s.flush() {
return false
}
return s.flushNotify(n)
}
s.pending = mergeBoringNotifies(s.pending, n)
d := s.clock.Now().Sub(s.lastFlush)
if d > s.interval {
return s.flush()
}
nextFlushIn := s.interval - d
if s.flushTimer == nil {
s.flushTimer, s.flushTimerC = s.clock.NewTimer(nextFlushIn)
} else {
s.flushTimer.Reset(nextFlushIn)
}
return true
}
func (s *rateLimitingBusSender) Run(ctx context.Context, ch <-chan *ipn.Notify) {
for {
select {
case <-ctx.Done():
return
case n, ok := <-ch:
if !ok {
return
}
if !s.send(n) {
return
}
if f := s.didSendTestHook; f != nil {
f()
}
case <-s.flushChan():
if !s.flush() {
return
}
}
}
}
// mergeBoringNotify merges new notify 'src' into possibly-nil 'dst',
// either mutating 'dst' or allocating a new one if 'dst' is nil,
// returning the merged result.
//
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
if dst == nil {
dst = &ipn.Notify{Version: src.Version}
}
if src.NetMap != nil {
dst.NetMap = src.NetMap
}
if src.Engine != nil {
dst.Engine = src.Engine
}
return dst
}
// isNotableNotify reports whether n is a "notable" notification that
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.
//
// It effectively reports whether n contains any field set that's
// not NetMap or Engine.
func isNotableNotify(n *ipn.Notify) bool {
if n == nil {
return false
}
return n.State != nil ||
n.SessionID != "" ||
n.BrowseToURL != nil ||
n.LocalTCPPort != nil ||
n.ClientVersion != nil ||
n.Prefs != nil ||
n.ErrMessage != nil ||
n.LoginFinished != nil ||
!n.DriveShares.IsNil() ||
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
len(n.OutgoingFiles) > 0 ||
n.FilesWaiting != nil ||
n.SuggestedExitNode != nil
}
+31 -24
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -44,9 +44,6 @@ func init() {
// several candidate nodes is reachable and actually alive.
RegisterC2N("/echo", handleC2NEcho)
}
if buildfeatures.HasSSH {
RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
}
if buildfeatures.HasLogTail {
RegisterC2N("POST /logtail/flush", handleC2NLogtailFlush)
}
@@ -86,6 +83,29 @@ func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *htt
c2nHandlers[k] = h
}
// RegisterC2NPrefix registers h as the c2n handler for all paths starting
// with prefix. prefix must end in "/". Prefix matches are tried after all
// exact-path handlers registered via [RegisterC2N] fail to match.
func RegisterC2NPrefix(prefix string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasC2N {
return
}
if prefix == "" || !strings.HasSuffix(prefix, "/") {
panic(fmt.Sprintf("c2n: prefix %q must be non-empty and end with /", prefix))
}
c2nPrefixHandlers = append(c2nPrefixHandlers, c2nPrefixHandler{prefix, h})
}
// c2nPrefixHandler is a c2n handler that matches all paths starting with prefix.
type c2nPrefixHandler struct {
prefix string
h c2nHandler
}
// c2nPrefixHandlers are c2n handlers matched by URL path prefix rather than
// exact path. See [RegisterC2NPrefix].
var c2nPrefixHandlers []c2nPrefixHandler
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
type methodAndPath struct {
@@ -121,6 +141,13 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
h(b, w, r)
return
}
// Then try prefix matches.
for _, ph := range c2nPrefixHandlers {
if strings.HasPrefix(r.URL.Path, ph.prefix) {
ph.h(b, w, r)
return
}
}
if c2nHandlerPaths.Contains(r.URL.Path) {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
} else {
@@ -290,26 +317,6 @@ func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
c2nPprof(w, r, profile)
}
func handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasSSH {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
var req tailcfg.C2NSSHUsernamesRequest
if r.Method == "POST" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
res, err := b.getSSHUsernames(&req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, res)
}
func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if b.sockstatLogger == nil {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !wasm && !ts_omit_debug
-186
View File
@@ -1,186 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_captiveportal
package ipnlocal
import (
"context"
"time"
"tailscale.com/health"
"tailscale.com/net/captivedetection"
"tailscale.com/util/clientmetric"
)
func init() {
hookCaptivePortalHealthChange.Set(captivePortalHealthChange)
hookCheckCaptivePortalLoop.Set(checkCaptivePortalLoop)
}
var metricCaptivePortalDetected = clientmetric.NewCounter("captiveportal_detected")
// captivePortalDetectionInterval is the duration to wait in an unhealthy state with connectivity broken
// before running captive portal detection.
const captivePortalDetectionInterval = 2 * time.Second
func captivePortalHealthChange(b *LocalBackend, state *health.State) {
isConnectivityImpacted := false
for _, w := range state.Warnings {
// Ignore the captive portal warnable itself.
if w.ImpactsConnectivity && w.WarnableCode != captivePortalWarnable.Code {
isConnectivityImpacted = true
break
}
}
// captiveCtx can be changed, and is protected with 'mu'; grab that
// before we start our select, below.
//
// It is guaranteed to be non-nil.
b.mu.Lock()
ctx := b.captiveCtx
b.mu.Unlock()
// If the context is canceled, we don't need to do anything.
if ctx.Err() != nil {
return
}
if isConnectivityImpacted {
b.logf("health: connectivity impacted; triggering captive portal detection")
// Ensure that we select on captiveCtx so that we can time out
// triggering captive portal detection if the backend is shutdown.
select {
case b.needsCaptiveDetection <- true:
case <-ctx.Done():
}
} else {
// If connectivity is not impacted, we know for sure we're not behind a captive portal,
// so drop any warning, and signal that we don't need captive portal detection.
b.health.SetHealthy(captivePortalWarnable)
select {
case b.needsCaptiveDetection <- false:
case <-ctx.Done():
}
}
}
// captivePortalWarnable is a Warnable which is set to an unhealthy state when a captive portal is detected.
var captivePortalWarnable = health.Register(&health.Warnable{
Code: "captive-portal-detected",
Title: "Captive portal detected",
// High severity, because captive portals block all traffic and require user intervention.
Severity: health.SeverityHigh,
Text: health.StaticMessage("This network requires you to log in using your web browser."),
ImpactsConnectivity: true,
})
func checkCaptivePortalLoop(b *LocalBackend, ctx context.Context) {
var tmr *time.Timer
maybeStartTimer := func() {
// If there's an existing timer, nothing to do; just continue
// waiting for it to expire. Otherwise, create a new timer.
if tmr == nil {
tmr = time.NewTimer(captivePortalDetectionInterval)
}
}
maybeStopTimer := func() {
if tmr == nil {
return
}
if !tmr.Stop() {
<-tmr.C
}
tmr = nil
}
for {
if ctx.Err() != nil {
maybeStopTimer()
return
}
// First, see if we have a signal on our "healthy" channel, which
// takes priority over an existing timer. Because a select is
// nondeterministic, we explicitly check this channel before
// entering the main select below, so that we're guaranteed to
// stop the timer before starting captive portal detection.
select {
case needsCaptiveDetection := <-b.needsCaptiveDetection:
if needsCaptiveDetection {
maybeStartTimer()
} else {
maybeStopTimer()
}
default:
}
var timerChan <-chan time.Time
if tmr != nil {
timerChan = tmr.C
}
select {
case <-ctx.Done():
// All done; stop the timer and then exit.
maybeStopTimer()
return
case <-timerChan:
// Kick off captive portal check
b.performCaptiveDetection()
// nil out timer to force recreation
tmr = nil
case needsCaptiveDetection := <-b.needsCaptiveDetection:
if needsCaptiveDetection {
maybeStartTimer()
} else {
// Healthy; cancel any existing timer
maybeStopTimer()
}
}
}
}
// shouldRunCaptivePortalDetection reports whether captive portal detection
// should be run. It is enabled by default, but can be disabled via a control
// knob. It is also only run when the user explicitly wants the backend to be
// running.
func (b *LocalBackend) shouldRunCaptivePortalDetection() bool {
b.mu.Lock()
defer b.mu.Unlock()
return !b.ControlKnobs().DisableCaptivePortalDetection.Load() && b.pm.prefs.WantRunning()
}
// performCaptiveDetection checks if captive portal detection is enabled via controlknob. If so, it runs
// the detection and updates the Warnable accordingly.
func (b *LocalBackend) performCaptiveDetection() {
if !b.shouldRunCaptivePortalDetection() {
return
}
d := captivedetection.NewDetector(b.logf)
b.mu.Lock() // for b.hostinfo
cn := b.currentNode()
dm := cn.DERPMap()
preferredDERP := 0
if b.hostinfo != nil {
if b.hostinfo.NetInfo != nil {
preferredDERP = b.hostinfo.NetInfo.PreferredDERP
}
}
ctx := b.ctx
netMon := b.NetMon()
b.mu.Unlock()
found := d.Detect(ctx, netMon, dm, preferredDERP)
if found {
if !b.health.IsUnhealthy(captivePortalWarnable) {
metricCaptivePortalDetected.Add(1)
}
b.health.SetUnhealthy(captivePortalWarnable, health.Args{})
} else {
b.health.SetHealthy(captivePortalWarnable)
}
}
+111 -882
View File
File diff suppressed because it is too large. Load diff
-45
View File
@@ -1,45 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build js || ts_omit_acme
package ipnlocal
import (
"context"
"errors"
"io"
"net/http"
"time"
)
func init() {
RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatusDisabled)
}
var errNoCerts = errors.New("cert support not compiled in this build")
type TLSCertKeyPair struct {
CertPEM, KeyPEM []byte
}
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
return nil, errNoCerts
}
var errCertExpired = errors.New("cert expired")
type certStore interface{}
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
return nil, errNoCerts
}
func (b *LocalBackend) getCertStore() (certStore, error) {
return nil, errNoCerts
}
func handleC2NTLSCertStatusDisabled(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"Missing":true}`) // a minimal tailcfg.C2NTLSCertInfo
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"errors"
"fmt"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn/ipnlocal/netmapcache"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// diskCache is the state netmap caching to disk.
type diskCache struct {
// all fields guarded by LocalBackend.mu
dir string // active profile cache directory
cache *netmapcache.Cache
}
// writePeerDeltaToDiskLocked applies the specified peer delta to the cache,
// leaving other existing cached fields (self, profiles, other peers not
// mentioned in the arguments) unchanged. It does nothing (without error) if
// both slices are empty.
func (b *LocalBackend) writePeerDeltaToDiskLocked(update []tailcfg.NodeView, remove []tailcfg.StableNodeID) error {
if !buildfeatures.HasCacheNetMap || (len(update) == 0 && len(remove) == 0) {
return nil
} else if err := b.ensureDiskCacheLocked(); err != nil {
return err
}
// Do not log this, we get deltas all the time and it's very noisy.
return b.diskCache.cache.UpdatePeers(b.currentNode().Context(), update, remove)
}
// writeNetmapToDiskLockedWithoutPeers updates nm in the cache, excluding peers and profiles.
func (b *LocalBackend) writeNetmapToDiskLockedWithoutPeers(nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
} else if err := b.ensureDiskCacheLocked(); err != nil {
return err
}
b.logf("updating netmap in disk cache")
return b.diskCache.cache.UpdateSelfOnly(b.currentNode().Context(), b.patchNetmapHomeDERPLocked(nm))
}
// writeNetmapToDiskLockedWithPeers writes nm into the cache, including peers and profiles.
func (b *LocalBackend) writeNetmapToDiskLockedWithPeers(nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
} else if err := b.ensureDiskCacheLocked(); err != nil {
return err
}
b.logf("writing netmap to disk cache")
return b.diskCache.cache.Store(b.currentNode().Context(), b.patchNetmapHomeDERPLocked(nm))
}
func (b *LocalBackend) patchNetmapHomeDERPLocked(nm *netmap.NetworkMap) *netmap.NetworkMap {
// Set the homeDERP on the self node before saving. The self node homeDERP is
// generally not used since the homeDERP for self is stored in magicsock, but
// to be able to load it during loading the cache, we use the existing field
// to save it.
// Make a shallow copy and mutate a copy of the selfNode.
nmCopy := *nm
selfNode := nm.SelfNode.AsStruct()
selfNode.HomeDERP = tailcfg.DERPRegionID(b.currentNode().homeDERP.Load())
nmCopy.SelfNode = selfNode.View()
return &nmCopy
}
func (b *LocalBackend) ensureDiskCacheLocked() error {
dir, err := b.profileMkdirAllLocked(b.pm.CurrentProfile().ID(), "netmap-cache")
if err != nil {
return err
}
if c := b.diskCache; c.cache == nil || c.dir != dir {
b.diskCache.cache = netmapcache.NewCache(netmapcache.FileStore(dir))
b.diskCache.dir = dir
}
return nil
}
func (b *LocalBackend) loadDiskCacheLocked() (om *netmap.NetworkMap, ok bool) {
if !buildfeatures.HasCacheNetMap {
return nil, false
}
dir, err := b.profileMkdirAllLocked(b.pm.CurrentProfile().ID(), "netmap-cache")
if err != nil {
b.logf("profile data directory: %v", err)
return nil, false
}
if c := b.diskCache; c.cache == nil || c.dir != dir {
b.diskCache.cache = netmapcache.NewCache(netmapcache.FileStore(dir))
b.diskCache.dir = dir
}
nm, err := b.diskCache.cache.Load(b.currentNode().Context())
if err != nil {
b.logf("load netmap from cache: %v", err)
return nil, false
}
return nm, true
}
// discardDiskCacheLocked removes a cached network map for the current node, if
// one exists, and disables the cache.
func (b *LocalBackend) discardDiskCacheLocked() {
if !buildfeatures.HasCacheNetMap {
return
}
if b.diskCache.cache == nil {
return // nothing to do, we do not have a cache
}
// Reaching here, we have a cache directory that needs to be purged.
// Log errors but do not fail for them.
store := netmapcache.FileStore(b.diskCache.dir)
if err := b.clearStoreLocked(b.currentNode().Context(), store); err != nil {
b.logf("clearing netmap cache: %v", err)
}
b.diskCache = diskCache{} // drop in-memory state
}
// clearStoreLocked discards all the keys in the specified store.
func (b *LocalBackend) clearStoreLocked(ctx context.Context, store netmapcache.Store) error {
var errs []error
for key, err := range store.List(ctx, "") {
if err != nil {
errs = append(errs, fmt.Errorf("list cache contest: %w", err))
break
}
if err := store.Remove(ctx, key); err != nil {
errs = append(errs, fmt.Errorf("discard cache key %q: %w", key, err))
}
}
return errors.Join(errs...)
}
// ClearNetmapCache discards stored netmap caches (if any) for profiles for the
// current user of b. It also drops any cache from the active backend session,
// if there is one.
func (b *LocalBackend) ClearNetmapCache(ctx context.Context) error {
if !buildfeatures.HasCacheNetMap {
return nil // disabled
}
b.mu.Lock()
defer b.mu.Unlock()
var errs []error
for _, p := range b.pm.Profiles() {
store := netmapcache.FileStore(b.profileDataPathLocked(p.ID(), "netmap-cache"))
err := b.clearStoreLocked(ctx, store)
if err != nil {
errs = append(errs, fmt.Errorf("clear netmap cache for profile %q: %w", p.ID(), err))
}
}
b.diskCache = diskCache{} // drop in-memory state
return errors.Join(errs...)
}
+107 -72
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"net/http"
"net/netip"
"os"
@@ -16,22 +17,43 @@ import (
"tailscale.com/drive"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
func init() {
hookSetNetMapLockedDrive.Set(setNetMapLockedDrive)
hookInstallDriveRemoteSource.Set(installDriveRemoteSource)
}
func setNetMapLockedDrive(b *LocalBackend, nm *netmap.NetworkMap) {
b.updateDrivePeersLocked(nm)
// setNetMapLockedDrive runs on every full netmap install (the only path that
// can flip self caps or change the tailnet domain) to re-notify IPN bus
// listeners of the current local shares.
//
// It deliberately does NOT touch the remotes list passed to the local drive
// filesystem: that flows through [driveRemoteSource], which the filesystem
// pulls from lazily and only when something might have changed. See
// [LocalBackend.driveGen].
func setNetMapLockedDrive(b *LocalBackend) {
b.driveNotifyCurrentSharesLocked()
}
// installDriveRemoteSource registers a [drive.RemoteSource] on the local
// Taildrive filesystem so it can pull the current set of remotes on demand
// instead of being pushed a fresh list on every netmap update.
//
// It is wired from [NewLocalBackend] via [hookInstallDriveRemoteSource] so
// non-drive builds don't reference the drive package at all.
func installDriveRemoteSource(b *LocalBackend) {
fs, ok := b.sys.DriveForLocal.GetOK()
if !ok {
return
}
fs.SetRemoteSource(driveRemoteSource{b})
}
// DriveSetServerAddr tells Taildrive to use the given address for connecting
// to the drive.FileServer that's exposing local files as an unprivileged
// user.
@@ -285,80 +307,93 @@ func (b *LocalBackend) DriveGetShares() views.SliceView[*drive.Share, drive.Shar
return b.pm.prefs.DriveShares()
}
// updateDrivePeersLocked sets all applicable peers from the netmap as Taildrive
// remotes.
func (b *LocalBackend) updateDrivePeersLocked(nm *netmap.NetworkMap) {
fs, ok := b.sys.DriveForLocal.GetOK()
if !ok {
return
}
// driveRemoteSource implements [drive.RemoteSource] by reading from a
// [LocalBackend]. It is installed once on the local Taildrive filesystem
// at [NewLocalBackend] time and consulted lazily on incoming WebDAV
// requests.
//
// The filesystem only rebuilds its internal child list when [Generation]
// changes, so peer churn that doesn't affect the drive-capable peer set
// (e.g. endpoint or online-status mutations on non-drive peers) costs
// nothing more than a single atomic load.
type driveRemoteSource struct{ b *LocalBackend }
var driveRemotes []*drive.Remote
if b.DriveAccessEnabled() {
// Only populate peers if access is enabled, otherwise leave blank.
driveRemotes = b.driveRemotesFromPeers(nm)
}
fs.SetRemotes(nm.Domain, driveRemotes, b.newDriveTransport())
// Generation implements [drive.RemoteSource].
func (s driveRemoteSource) Generation() uint64 {
return s.b.driveGen.Load()
}
func (b *LocalBackend) driveRemotesFromPeers(nm *netmap.NetworkMap) []*drive.Remote {
b.logf("[v1] taildrive: setting up drive remotes from peers")
driveRemotes := make([]*drive.Remote, 0, len(nm.Peers))
for _, p := range nm.Peers {
peer := p
peerID := peer.ID()
peerKey := peer.Key().ShortString()
b.logf("[v1] taildrive: appending remote for peer %s", peerKey)
driveRemotes = append(driveRemotes, &drive.Remote{
Name: p.DisplayName(false),
URL: func() string {
url := fmt.Sprintf("%s/%s", b.currentNode().PeerAPIBase(peer), taildrivePrefix[1:])
b.logf("[v2] taildrive: url for peer %s: %s", peerKey, url)
return url
},
Available: func() bool {
// Peers are available to Taildrive if:
// - They are online
// - Their PeerAPI is reachable
// - They are allowed to share at least one folder with us
cn := b.currentNode()
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: Available(): peer %s not found", peerKey)
return false
}
// Domain implements [drive.RemoteSource].
func (s driveRemoteSource) Domain() string {
nm := s.b.currentNode().NetMap()
if nm == nil {
return ""
}
return nm.Domain
}
// Exclude offline peers.
// TODO(oxtoacart): for some reason, this correctly
// catches when a node goes from offline to online,
// but not the other way around...
// TODO(oxtoacart,nickkhyl): the reason was probably
// that we were using netmap.Peers instead of b.peers.
// The netmap.Peers slice is not updated in all cases.
// It should be fixed now that we use PeerByIDOk.
if !peer.Online().Get() {
b.logf("[v2] taildrive: Available(): peer %s offline", peerKey)
return false
}
// Transport implements [drive.RemoteSource].
func (s driveRemoteSource) Transport() http.RoundTripper {
return s.b.newDriveTransport()
}
if b.currentNode().PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: Available(): peer %s PeerAPI unreachable", peerKey)
// Remotes implements [drive.RemoteSource]. It yields one [*drive.Remote]
// per peer; the per-Remote Available closure filters out peers that
// aren't online, don't expose PeerAPI, or haven't granted us the
// Taildrive sharer cap. If drive access is disabled on this node, no
// remotes are yielded at all, so the filesystem ends up with an empty
// child list.
func (s driveRemoteSource) Remotes() iter.Seq[*drive.Remote] {
return func(yield func(*drive.Remote) bool) {
b := s.b
if !b.DriveAccessEnabled() {
return
}
cn := b.currentNode()
peers := cn.Peers()
b.logf("[v1] taildrive: building drive remotes from %d peers", len(peers))
for _, peer := range peers {
peerID := peer.ID()
peerKey := peer.Key().ShortString()
peerName := peer.DisplayName(false)
r := &drive.Remote{
Name: peerName,
URL: func() string {
url := fmt.Sprintf("%s/%s", cn.PeerAPIBase(peer), taildrivePrefix[1:])
b.logf("[v2] taildrive: url for peer %s (%s): %s", peerKey, peerName, url)
return url
},
Available: func() bool {
// Peers are available to Taildrive if:
// - They are online
// - Their PeerAPI is reachable
// - They are allowed to share at least one folder with us
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: peer %s (%s, id=%v) not found", peerKey, peerName, peerID)
return false
}
if !peer.Online().Get() {
b.logf("[v2] taildrive: peer %s (%s, id=%v) offline", peerKey, peerName, peerID)
return false
}
if cn.PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: peer %s (%s, id=%v) PeerAPI unreachable", peerKey, peerName, peerID)
return false
}
if cn.PeerHasCap(peer, peercap.TaildriveSharer) {
b.logf("[v2] taildrive: peer %s (%s, id=%v) available", peerKey, peerName, peerID)
return true
}
b.logf("[v2] taildrive: peer %s (%s, id=%v) not allowed to share", peerKey, peerName, peerID)
return false
}
// Check that the peer is allowed to share with us.
if cn.PeerHasCap(peer, tailcfg.PeerCapabilityTaildriveSharer) {
b.logf("[v2] taildrive: Available(): peer %s available", peerKey)
return true
}
b.logf("[v2] taildrive: Available(): peer %s not allowed to share", peerKey)
return false
},
})
},
}
if !yield(r) {
return
}
}
}
return driveRemotes
}
// responseBodyWrapper wraps an io.ReadCloser and stores
+4 -4
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// This is the Taildrive stuff that should ideally be registered in init only when
@@ -7,7 +7,7 @@
package ipnlocal
import "tailscale.com/tailcfg"
import "tailscale.com/tailcfg/nodecap"
const (
// DriveLocalPort is the port on which the Taildrive listens for location
@@ -19,12 +19,12 @@ const (
// enabled. This is currently based on checking for the drive:share node
// attribute.
func (b *LocalBackend) DriveSharingEnabled() bool {
return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveShare)
return b.currentNode().SelfHasCap(nodecap.TaildriveShare)
}
// DriveAccessEnabled reports whether accessing Taildrive shares on remote nodes
// is enabled. This is currently based on checking for the drive:access node
// attribute.
func (b *LocalBackend) DriveAccessEnabled() bool {
return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveAccess)
return b.currentNode().SelfHasCap(nodecap.TaildriveAccess)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
+29 -4
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strings"
@@ -90,6 +89,11 @@ type ExtensionHost struct {
extByType sync.Map // reflect.Type -> ipnext.Extension
// hasPendingAuthReconfig tracks whether an AuthReconfig call
// has been enqueued but not yet executed. It avoids redundant
// reconfigs and prevents them from piling up in the workQueue.
hasPendingAuthReconfig atomic.Bool
// mu protects the following fields.
// It must not be held when calling [LocalBackend] methods
// or when invoking callbacks registered by extensions.
@@ -124,6 +128,8 @@ type Backend interface {
NodeBackend() ipnext.NodeBackend
authReconfig()
ipnext.SafeBackend
}
@@ -262,7 +268,10 @@ func (h *ExtensionHost) init() {
// Report active extensions to the log.
// TODO(nickkhyl): update client metrics to include the active/failed/skipped extensions.
h.mu.Lock()
extensionNames := slices.Collect(maps.Keys(h.extensionsByName))
var extensionNames []string
for _, ext := range h.activeExtensions {
extensionNames = append(extensionNames, ext.Name())
}
h.mu.Unlock()
h.logf("active extensions: %v", strings.Join(extensionNames, ", "))
@@ -339,7 +348,7 @@ func (h *ExtensionHost) FindMatchingExtension(target any) bool {
val := reflect.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflect.Ptr || val.IsNil() {
if typ.Kind() != reflect.Pointer || val.IsNil() {
panic("ipnext: target must be a non-nil pointer")
}
targetType := typ.Elem()
@@ -541,6 +550,22 @@ func (h *ExtensionHost) Shutdown() {
h.shutdownOnce.Do(h.shutdown)
}
// AuthReconfigAsync implements [ipnext.Host.AuthReconfigAsync].
// Since execution uses the most recent state at execution time,
// multiple enqueued calls are redundant and are not enqueued.
func (h *ExtensionHost) AuthReconfigAsync() {
if h == nil {
return
}
if h.hasPendingAuthReconfig.Swap(true) {
return
}
h.enqueueBackendOperation(func(b Backend) {
h.hasPendingAuthReconfig.Store(false)
b.authReconfig()
})
}
func (h *ExtensionHost) shutdown() {
h.shuttingDown.Store(true)
// Prevent any queued but not yet started operations from running,
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"crypto/tls"
"net/http"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/util/testenv"
"tailscale.com/wgengine/filter"
)
// forTest is an unexported type to hide all the test-only
// methods on [LocalBackend] from godoc.
type forTest struct{ b *LocalBackend }
// ForTest returns a handle to test-only methods on b.
// The resulting type is unexported to make it very obvious
// in godoc that this is not stable API. This method panics
// if called outside of tests, which also centralizes all
// must-be-in-tests validation.
func (b *LocalBackend) ForTest() forTest {
testenv.AssertInTest()
return forTest{b}
}
// HandleC2N calls [LocalBackend.handleC2N], for use by feature/ packages that
// register C2N handlers and want to test them.
func (f forTest) HandleC2N(w http.ResponseWriter, r *http.Request) {
f.b.handleC2N(w, r)
}
// SetIPServiceMappings overwrites the LocalBackend's IP-to-service mappings
// and propagates them to the netstack subsystem if registered.
func (f forTest) SetIPServiceMappings(m netmap.IPServiceMappings) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.ipVIPServiceMap = m
if ns, ok := b.sys.Netstack.GetOK(); ok {
ns.UpdateIPServiceMappings(m)
}
}
// GetFilter returns the current packet filter.
func (f forTest) GetFilter() *filter.Filter {
b := f.b
// Take b.mu so the read serializes with [LocalBackend.setControlClientStatusLocked],
// which installs the netmap and the filter at separate sub-steps. Without
// this, a test thread that observes the new netmap (via [LocalBackend.NetMapWithPeers])
// can race ahead of the filter store and read the previous filter.
b.mu.Lock()
defer b.mu.Unlock()
return b.currentNode().filterAtomic.Load()
}
// SetControlClientGetter sets the func that creates a control plane
// client. It can be called at most once, before Start.
func (f forTest) SetControlClientGetter(newControlClient func(controlclient.Options) (controlclient.Client, error)) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
if b.ccGen != nil {
panic("invalid use of forTest.SetControlClientGetter after Start")
}
b.ccGen = newControlClient
}
// Peers returns all the current peers, sorted by Node.ID, for integration
// tests in another repo.
func (f forTest) Peers() []tailcfg.NodeView {
return f.b.currentNode().PeersForTest()
}
// AwaitNodeKey returns a channel that is closed once a peer with the given
// node key first appears in the current netmap. If the peer is already
// present, the returned channel is already closed. See
// [nodeBackend.AwaitNodeKeyForTest].
func (f forTest) AwaitNodeKey(k key.NodePublic) <-chan struct{} {
return f.b.currentNode().AwaitNodeKeyForTest(k)
}
// CurrentUser returns the current user and the associated WindowsUserID.
// It will be removed along with the rest of the "current user" functionality
// as we progress on the multi-user improvements (tailscale/corp#18342).
func (f forTest) CurrentUser() (ipn.WindowsUserID, ipnauth.Actor) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
return b.pm.CurrentUserID(), b.currentUser
}
// ConfigureCerts sets a certificate retrieval function to be used by this
// local backend, skipping the usual ACME certificate registration.
func (f forTest) ConfigureCerts(getCert func(hostname string) (*TLSCertKeyPair, error)) {
hook, ok := HookConfigureCertsForTest.GetOk()
if !ok {
panic("forTest.ConfigureCerts called without cert extension registered")
}
hook(f.b, getCert)
}
// GetACMETLSALPNCert returns the short-lived ACME tls-alpn-01 challenge
// certificate for hi, if any.
func (f forTest) GetACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
return f.b.getACMETLSALPNCert(hi)
}
// SetServeConfig installs sc as the backend's current
// [ipn.ServeConfig] without going through the validation in
// [LocalBackend.SetServeConfig]. It is intended for tests that need a
// specific serve config without first standing up the prerequisites
// (netmap, prefs, etc.).
func (f forTest) SetServeConfig(sc ipn.ServeConfigView) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.serveConfig = sc
}
// SetNetMap installs nm as the backend's current netmap without going
// through control-plane plumbing. It is intended for tests that need a
// specific netmap (e.g. CertDomains, capabilities).
func (f forTest) SetNetMap(nm *netmap.NetworkMap) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.currentNode().SetNetMap(nm)
}
// SetClock replaces b's clock with c, for tests that need
// time-dependent behavior to be deterministic.
func (f forTest) SetClock(c tstime.Clock) { f.b.clock = c }
// SetPrefs replaces the current prefs with newp.
func (f forTest) SetPrefs(newp *ipn.Prefs) {
if newp == nil {
panic("forTest.SetPrefs got nil prefs")
}
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.setPrefsLocked(newp)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tpm
+1989 -541
View File
File diff suppressed because it is too large. Load diff
+452
View File
@@ -0,0 +1,452 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netmapcache implements a persistent cache for [netmap.NetworkMap]
// values, allowing a client to start up using stale but previously-valid state
// even if a connection to the control plane is not immediately available.
package netmapcache
import (
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
jsonv1 "encoding/json"
"errors"
"fmt"
"io/fs"
"iter"
"os"
"path/filepath"
"slices"
"strings"
"time"
"tailscale.com/feature/buildfeatures"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/netmap"
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/wgengine/filter"
)
var (
// ErrKeyNotFound is a sentinel error reported by implementations of the [Store]
// interface when loading a key that is not found in the store.
ErrKeyNotFound = errors.New("storage key not found")
// ErrCacheNotAvailable is a sentinel error reported by cache methods when
// the netmap caching feature is not enabled in the build.
ErrCacheNotAvailable = errors.New("netmap cache is not available")
)
// A Cache manages a columnar cache of a [netmap.NetworkMap]. Each Cache holds
// a single netmap value; use [Cache.Store] to update or replace the cached
// value and [Cache.Load] to read the cached value.
type Cache struct {
store Store
// wantKeys records the cache keys from the last write or load of a cached
// netmap. This is used to prune keys that are no longer referenced after an
// update.
wantKeys set.Set[cacheKey]
// lastWrote records the last values written to each stored key.
//
// TODO(creachadair): This is meant to avoid disk writes, but I'm not
// convinced we need it. Or maybe just track hashes of the content rather
// than caching a complete copy.
lastWrote map[cacheKey]lastWrote
}
// NewCache constructs a new empty [Cache] from the given [Store].
// It will panic if s == nil.
func NewCache(s Store) *Cache {
if s == nil {
panic("a non-nil Store is required")
}
return &Cache{
store: s,
wantKeys: make(set.Set[cacheKey]),
lastWrote: make(map[cacheKey]lastWrote),
}
}
type lastWrote struct {
digest string
at time.Time
}
func (c *Cache) writeJSON(ctx context.Context, key cacheKey, v any) error {
j, err := jsonv1.Marshal(v)
if err != nil {
return fmt.Errorf("JSON marshalling %q: %w", key, err)
}
// If the digest of this value has not changed since it was last written,
// record that the key is of interest, but do not actually perform a write.
last, ok := c.lastWrote[key]
if ok && cacheDigest(j) == last.digest {
c.wantKeys.Add(key)
return nil
}
if err := c.store.Store(ctx, string(key), j); err != nil {
return err
}
// Track the storage keys the current map is using, for storage GC.
c.wantKeys.Add(key)
c.lastWrote[key] = lastWrote{
digest: cacheDigest(j),
at: time.Now(),
}
return nil
}
func (c *Cache) removeUnwantedKeys(ctx context.Context) error {
var errs []error
for key, err := range c.store.List(ctx, "") {
if err != nil {
errs = append(errs, err)
break
}
ckey := cacheKey(key)
if !c.wantKeys.Contains(ckey) {
if err := c.store.Remove(ctx, key); err != nil {
errs = append(errs, fmt.Errorf("remove key %q: %w", key, err))
}
delete(c.lastWrote, ckey) // even if removal failed, we don't want it
}
}
return errors.Join(errs...)
}
// FileStore implements the [Store] interface using a directory of files, in
// which each key is encoded as a filename in the directory.
// The caller is responsible to ensure the directory path exists before
// using the store methods.
type FileStore string
// List implements part of the [Store] interface.
func (s FileStore) List(ctx context.Context, prefix string) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
des, err := os.ReadDir(string(s))
if os.IsNotExist(err) {
return // nothing to read
} else if err != nil {
yield("", err)
return
}
// os.ReadDir reports entries already sorted, and the encoding preserves that.
for _, de := range des {
key, err := hex.DecodeString(de.Name())
if err != nil {
yield("", err)
return
}
name := string(key)
if !strings.HasPrefix(name, prefix) {
continue
} else if !yield(name, nil) {
return
}
}
}
}
// Load implements part of the [Store] interface.
func (s FileStore) Load(ctx context.Context, key string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("key %q not found: %w", key, ErrKeyNotFound)
}
return data, err
}
// Store implements part of the [Store] interface.
func (s FileStore) Store(ctx context.Context, key string, value []byte) error {
return os.WriteFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))), value, 0600)
}
// Remove implements part of the [Store] interface.
func (s FileStore) Remove(ctx context.Context, key string) error {
err := os.Remove(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
// cacheKey is a type wrapper for strings used as cache keys.
type cacheKey string
const (
selfKey cacheKey = "self"
miscKey cacheKey = "msic"
dnsKey cacheKey = "dns"
derpMapKey cacheKey = "derpmap"
peerKeyPrefix cacheKey = "peer-" // + stable ID
userKeyPrefix cacheKey = "user-" // + profile ID
sshPolicyKey cacheKey = "ssh"
packetFilterKey cacheKey = "filter"
)
// UpdateSelfOnly updates nm in the cache, replacing any previously cached
// values for nm.Self and other tailnet metadata for the current node, but
// skipping the Peers and UserProfiles fields. Any existing peer and profile
// data are left unmodified.
func (c *Cache) UpdateSelfOnly(ctx context.Context, nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
}
if selfID := nm.User(); selfID == 0 {
return errors.New("no user in netmap")
}
// Since we are not modifying peers or users (and in particular, not
// removing any), we do not need to do any garbage collection on the storage
// keys.
return c.updateSelfOnly(ctx, nm)
}
// Store records nm in the cache, replacing any previously-cached values.
func (c *Cache) Store(ctx context.Context, nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
}
if selfID := nm.User(); selfID == 0 {
return errors.New("no user in netmap")
}
// Because we may modify which peers and user profiles are valid, we need to
// keep track of which storage keys we actually touch during the store.
// This is used by c.removeUnwantedKeys to clean up keys that are no longer
// referenced after peers and/or profiles are removed from nm.
clear(c.wantKeys)
if err := c.updateSelfOnly(ctx, nm); err != nil {
return err
}
for _, p := range nm.Peers {
key := peerKeyPrefix + cacheKey(p.StableID())
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
return err
}
}
for uid, u := range nm.UserProfiles {
key := fmt.Sprintf("%s%d", userKeyPrefix, uid)
if err := c.writeJSON(ctx, cacheKey(key), netmapUserProfile{UserProfile: &u}); err != nil {
return err
}
}
return c.removeUnwantedKeys(ctx)
}
// UpdatePeers updates the cache to add or replace ("upsert") any peers
// specified in update, and discard any peers specified in remove. This does
// not affect any previous cached values for the self node or user profiles.
//
// It is expected that update and remove will be disjoint, but in the event
// they are not, remove takes precedence. If the cache does not already contain
// at least a self node, UpdatePeers reports an error without changing anything.
func (c *Cache) UpdatePeers(ctx context.Context, update []tailcfg.NodeView, remove []tailcfg.StableNodeID) error {
if !buildfeatures.HasCacheNetMap {
return nil
}
if !c.wantKeys.Contains(selfKey) {
return errors.New("no netmap is cached to apply an update")
}
// Attempt all updates and removals before reporting any errors.
var errs []error
for _, p := range update {
// We may already have an entry for this peer, or it may be new, but
// rather than do a lookup we can just replace the existing entry if it
// exists.
key := peerKeyPrefix + cacheKey(p.StableID())
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
errs = append(errs, err)
continue
}
c.wantKeys.Add(key)
}
for _, q := range remove {
key := peerKeyPrefix + cacheKey(q)
if err := c.store.Remove(ctx, string(key)); err != nil {
errs = append(errs, err)
}
c.wantKeys.Delete(key)
delete(c.lastWrote, key)
}
return errors.Join(errs...)
}
// updateSelfOnly updates the "static" parts of the netmap in the cache. It is
// shared between [Cache.UpdateSelfOnly] and [Cache.Store].
func (c *Cache) updateSelfOnly(ctx context.Context, nm *netmap.NetworkMap) error {
if err := c.writeJSON(ctx, miscKey, netmapMisc{
MachineKey: &nm.MachineKey,
CollectServices: &nm.CollectServices,
DisplayMessages: &nm.DisplayMessages,
TKAEnabled: &nm.TKAEnabled,
TKAHead: &nm.TKAHead,
Domain: &nm.Domain,
DomainAuditLogID: &nm.DomainAuditLogID,
}); err != nil {
return err
}
if err := c.writeJSON(ctx, dnsKey, netmapDNS{DNS: &nm.DNS}); err != nil {
return err
}
if err := c.writeJSON(ctx, derpMapKey, netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
return err
}
if err := c.writeJSON(ctx, selfKey, netmapNode{Node: &nm.SelfNode}); err != nil {
return err
// N.B. The NodeKey and AllCaps fields can be recovered from SelfNode on
// load, and do not need to be stored separately.
}
if err := c.writeJSON(ctx, packetFilterKey, netmapPacketFilter{Rules: &nm.PacketFilterRules}); err != nil {
return err
}
if buildfeatures.HasSSH && nm.SSHPolicy != nil {
if err := c.writeJSON(ctx, sshPolicyKey, netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
return err
}
}
return nil
}
// Load loads the cached [netmap.NetworkMap] value stored in c, if one is available.
// It reports [ErrCacheNotAvailable] if no cached data are available.
// On success, the Cached field of the returned network map is true.
func (c *Cache) Load(ctx context.Context) (*netmap.NetworkMap, error) {
if !buildfeatures.HasCacheNetMap {
return nil, ErrCacheNotAvailable
}
nm := netmap.NetworkMap{Cached: true}
// At minimum, we require that the cache contain a "self" node, or the data
// are not usable.
if self, err := c.store.Load(ctx, string(selfKey)); errors.Is(err, ErrKeyNotFound) {
return nil, ErrCacheNotAvailable
} else if err := jsonv1.Unmarshal(self, &netmapNode{Node: &nm.SelfNode}); err != nil {
return nil, err
}
c.wantKeys.Add(selfKey)
// If we successfully recovered a SelfNode, pull out its related fields.
if s := nm.SelfNode; s.Valid() {
nm.NodeKey = s.Key()
nm.AllCaps = make(set.Set[nodecap.Cap])
for _, c := range s.Capabilities().All() {
nm.AllCaps.Add(c)
}
for c := range s.CapMap().All() {
nm.AllCaps.Add(c)
}
}
// Unmarshal the contents of each specified cache entry directly into the
// fields of the output. See the comment in types.go for more detail.
if err := c.readJSON(ctx, miscKey, &netmapMisc{
MachineKey: &nm.MachineKey,
CollectServices: &nm.CollectServices,
DisplayMessages: &nm.DisplayMessages,
TKAEnabled: &nm.TKAEnabled,
TKAHead: &nm.TKAHead,
Domain: &nm.Domain,
DomainAuditLogID: &nm.DomainAuditLogID,
}); err != nil {
return nil, err
}
if err := c.readJSON(ctx, dnsKey, &netmapDNS{DNS: &nm.DNS}); err != nil {
return nil, err
}
if err := c.readJSON(ctx, derpMapKey, &netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
return nil, err
}
for key, err := range c.store.List(ctx, string(peerKeyPrefix)) {
if err != nil {
return nil, err
}
var peer tailcfg.NodeView
if err := c.readJSON(ctx, cacheKey(key), &netmapNode{Node: &peer}); err != nil {
return nil, err
}
nm.Peers = append(nm.Peers, peer)
}
slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int { return cmp.Compare(a.ID(), b.ID()) })
for key, err := range c.store.List(ctx, string(userKeyPrefix)) {
if err != nil {
return nil, err
}
var up tailcfg.UserProfileView
if err := c.readJSON(ctx, cacheKey(key), &netmapUserProfile{UserProfile: &up}); err != nil {
return nil, err
}
mak.Set(&nm.UserProfiles, up.ID(), up)
}
if err := c.readJSON(ctx, sshPolicyKey, &netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
return nil, err
}
if err := c.readJSON(ctx, packetFilterKey, &netmapPacketFilter{Rules: &nm.PacketFilterRules}); err != nil {
return nil, err
} else if r := nm.PacketFilterRules; r.Len() != 0 {
// Reconstitute packet match expressions from the filter rules,
nm.PacketFilter, err = filter.MatchesFromFilterRules(r.AsSlice())
if err != nil {
return nil, err
}
}
return &nm, nil
}
func (c *Cache) readJSON(ctx context.Context, key cacheKey, value any) error {
data, err := c.store.Load(ctx, string(key))
if errors.Is(err, ErrKeyNotFound) {
return nil
} else if err != nil {
return err
}
if err := jsonv1.Unmarshal(data, value); err != nil {
return err
}
c.wantKeys.Add(key)
c.lastWrote[key] = lastWrote{digest: cacheDigest(data), at: time.Now()}
return nil
}
// Store is the interface to persistent key-value storage used by a [Cache].
type Store interface {
// List lists all the stored keys having the specified prefixes, in
// lexicographic order.
//
// Each pair yielded by the iterator is either a valid storage key and a nil
// error, or an empty key and a non-nil error. After reporting an error, the
// iterator must immediately return.
List(ctx context.Context, prefix string) iter.Seq2[string, error]
// Load fetches the contents of the specified key.
// If the key is not found in the store, Load must report [ErrKeyNotFound].
Load(ctx context.Context, key string) ([]byte, error)
// Store marshals and stores the contents of the specified value under key.
// If the key already exists, its contents are replaced.
Store(ctx context.Context, key string, value []byte) error
// Remove removes the specified key from the store. If the key does not exist,
// Remove reports success (nil).
Remove(ctx context.Context, key string) error
}
// cacheDigest computes a string digest of the specified data, for use in
// detecting cache hits.
func cacheDigest(data []byte) string { h := sha256.Sum256(data); return string(h[:]) }
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmapcache
import (
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
// The fields in the following wrapper types are all pointers, even when their
// target type is also a pointer, so that they can be used to unmarshal
// directly into the fields of another value. These wrappers intentionally do
// not omit zero or empty values, since we want the cache to reflect the value
// the object had at the time it was written, even if the default changes
// later.
//
// Moreover, these are all struct types so that each cached record will be a
// JSON object even if the underlying value marshals to an array or primitive
// type, and so that we have a seam if we want to replace or version the cached
// representation separately from the default JSON layout.
type netmapMisc struct {
MachineKey *key.MachinePublic
CollectServices *bool
DisplayMessages *map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
TKAEnabled *bool
TKAHead *tka.AUMHash
Domain *string
DomainAuditLogID *string
}
type netmapSSH struct {
SSHPolicy **tailcfg.SSHPolicy
}
type netmapDNS struct {
DNS *tailcfg.DNSConfig
}
type netmapDERPMap struct {
DERPMap **tailcfg.DERPMap
}
type netmapNode struct {
Node *tailcfg.NodeView
}
type netmapUserProfile struct {
UserProfile *tailcfg.UserProfileView
}
type netmapPacketFilter struct {
Rules *views.Slice[tailcfg.FilterRule]
// Match expressions are derived from the rules.
}
+2 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_netstack
@@ -11,7 +11,6 @@ import (
"time"
"gvisor.dev/gvisor/pkg/tcpip"
"tailscale.com/types/ptr"
)
// TCPHandlerForDst returns a TCP handler for connections to dst, or nil if
@@ -52,7 +51,7 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
// tell the difference between a long lived connection that is idle
// vs a connection that is dead because the peer has gone away.
// We pick 72h as that is typically sufficient for a long weekend.
opts = append(opts, ptr.To(tcpip.KeepaliveIdleOption(72*time.Hour)))
opts = append(opts, new(tcpip.KeepaliveIdleOption(72*time.Hour)))
return b.handleSSHConn, opts
}
// TODO(will,sonia): allow customizing web client port ?
+996 -188
View File
File diff suppressed because it is too large. Load diff
+190 -54
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -35,14 +35,21 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/testenv"
"tailscale.com/wgengine/filter"
)
// initListenConfig, if non-nil, is called during peerAPIListener setup. It is used only
// on iOS and macOS to set socket options to bind the listener to the Tailscale interface.
// initListenConfig, if non-nil, is called during peerAPIListener setup.
// On iOS and macOS, it sets socket options to bind the listener to the
// Tailscale interface. On Linux, it binds the listener to the tunnel
// interface, since peerapi traffic from other nodes is served by
// netstack in userspace and the kernel listener only needs to serve
// the local host.
var initListenConfig func(config *net.ListenConfig, addr netip.Addr, tunIfIndex int) error
// peerDNSQueryHandler is implemented by tsdns.Resolver.
@@ -55,14 +62,43 @@ type peerAPIServer struct {
resolver peerDNSQueryHandler
}
// skipKernelListener reports whether peerapi should not create a
// kernel-level listener at all and instead rely on netstack intercepting
// connections to the peerapi port. See [fakePeerAPIListener].
func (s *peerAPIServer) skipKernelListener() bool {
switch runtime.GOOS {
case "android":
// Android for whatever reason often has problems creating the
// peerapi listener (Issues 4449, 4293). Since netstack
// intercepts the connections anyway, the kernel listener isn't
// needed.
return true
case "freebsd":
// FreeBSD is a weak-host stack like Linux: a kernel listener on
// the node's Tailscale IP would answer TCP handshakes from
// LAN-adjacent machines that address that IP at the node's NIC
// (tailscale/corp#48248). Linux fixes that with SO_BINDTODEVICE
// and macOS/iOS with IP_BOUND_IF, but FreeBSD has no per-socket
// equivalent, so skip the kernel listener entirely whenever
// netstack is present. Without netstack (ts_omit_netstack),
// the kernel listener is the only way to serve peers, and
// protecting it is left to pf; see the log line in
// [peerAPIServer.listen].
_, hasNetstack := s.b.sys.Netstack.GetOK()
return hasNetstack
}
return false
}
func (s *peerAPIServer) listen(ip netip.Addr, tunIfIndex int) (ln net.Listener, err error) {
// Android for whatever reason often has problems creating the peerapi listener.
// But since we started intercepting it with netstack, it's not even important that
// we have a real kernel-level listener. So just create a dummy listener on Android
// and let netstack intercept it.
if runtime.GOOS == "android" {
if s.skipKernelListener() {
return newFakePeerAPIListener(ip), nil
}
if runtime.GOOS == "freebsd" {
// No netstack, so a kernel listener is required; see
// skipKernelListener for why it is exposed.
s.b.logf("peerapi: no netstack in this build; the kernel listener on %v answers connections from any interface (FreeBSD's weak host model), so restrict it to the tunnel interface with pf", ip)
}
ipStr := ip.String()
@@ -70,13 +106,10 @@ func (s *peerAPIServer) listen(ip netip.Addr, tunIfIndex int) (ln net.Listener,
if initListenConfig != nil {
// On iOS/macOS, this sets the lc.Control hook to
// setsockopt the interface index to bind to, to get
// out of the network sandbox.
// A zero tunIfIndex is invalid for peerapi. A zero value will not get us
// out of the network sandbox. Caller should log and retry.
if tunIfIndex == 0 {
return nil, fmt.Errorf("peerapi: cannot listen on %s with tunIfIndex 0", ipStr)
}
// out of the network sandbox. On Linux, it binds the
// listener to the tunnel interface so the kernel does
// not answer peerapi handshakes from the physical
// network; see initListenConfigTun.
if err := initListenConfig(&lc, ip, tunIfIndex); err != nil {
return nil, err
@@ -103,7 +136,7 @@ func (s *peerAPIServer) listen(ip netip.Addr, tunIfIndex int) (ln net.Listener,
// deterministic that people will bake this into clients.
// We try a few times just in case something's already
// listening on that port (on all interfaces, probably).
for try := uint8(0); try < 5; try++ {
for try := range uint8(5) {
a16 := ip.As16()
hashData := a16[len(a16)-3:]
hashData[0] += try
@@ -192,7 +225,7 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) {
c.Close()
return
}
nm := pln.lb.NetMap()
nm := pln.lb.NetMapNoPeers()
if nm == nil || !nm.SelfNode.Valid() {
logf("peerapi: no netmap")
c.Close()
@@ -263,10 +296,16 @@ func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
v4MasqAddr, hasMasqV4 := h.peerNode.SelfNodeV4MasqAddrForThisPeer().GetOk()
v6MasqAddr, hasMasqV6 := h.peerNode.SelfNodeV6MasqAddrForThisPeer().GetOk()
if hasMasqV4 || hasMasqV6 {
return addr == v4MasqAddr || addr == v6MasqAddr
// A masquerade address for a family, if set, replaces this node's
// native address of that family from the peer's point of view.
// A masquerade address for one family says nothing about the other
// family, so the other family still falls through to the self
// address check below.
if v4MasqAddr, ok := h.peerNode.SelfNodeV4MasqAddrForThisPeer().GetOk(); ok && addr.Is4() {
return addr == v4MasqAddr
}
if v6MasqAddr, ok := h.peerNode.SelfNodeV6MasqAddrForThisPeer().GetOk(); ok && addr.Is6() {
return addr == v6MasqAddr
}
pfx := netip.PrefixFrom(addr, addr.BitLen())
return views.SliceContains(h.selfNode.Addresses(), pfx)
@@ -578,7 +617,7 @@ func (h *peerAPIHandler) CanDebug() bool { return h.canDebug() }
// canDebug reports whether h can debug this node (goroutines, metrics,
// magicsock internal state, etc).
func (h *peerAPIHandler) canDebug() bool {
if !h.selfNode.HasCap(tailcfg.CapabilityDebug) {
if !h.selfNode.HasCap(nodecap.Debug) {
// This node does not expose debug info.
return false
}
@@ -586,17 +625,17 @@ func (h *peerAPIHandler) canDebug() bool {
// Unsigned peers can't debug.
return false
}
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityDebugPeer)
return h.isSelf || h.peerHasCap(peercap.DebugPeer)
}
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
// canIngress reports whether h can send ingress requests to this node.
func (h *peerAPIHandler) canIngress() bool {
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
return h.peerHasCap(peercap.Ingress) || (allowSelfIngress() && h.isSelf)
}
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
func (h *peerAPIHandler) peerHasCap(wantCap peercap.Cap) bool {
return h.PeerCaps().HasCapability(wantCap)
}
@@ -674,26 +713,107 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
dh.ServeHTTP(w, r)
}
func (h *peerAPIHandler) replyToDNSQueries() bool {
// DNSNameFilter allows extensions to conditionally allow PeerAPI DNS queries
// based on the name being queried, in addition to the source of the query. It
// is used by [HookReplyToDNSQueries].
type DNSNameFilter func(name string) (allowed bool)
// HookReplyToDNSQueries allows extensions to register a willingness to allow
// handling PeerAPI DNS queries for the peer making this request, optionally
// depending on the name being queried. The [http.Request.Body] must not be read
// by the handler.
// When sourceAllowed is false, the query is disallowed and nameAllowed is
// ignored (recommendation: nameAllowed should be nil in this case).
// When sourceAllowed is true, the peer is permitted to send queries but the
// final decision depends on the name being queried. When nameAllowed is nil,
// all names are allowed. Otherwise, nameAllowed is called after parsing the
// query to determine if it should be accepted.
// While separating the decisions complicates this hook, it permits optimizing
// the common case where all names are allowed for exit nodes and appc.
var HookReplyToDNSQueries = feature.Hooks[func(PeerAPIHandler, *http.Request) (sourceAllowed bool, nameAllowed DNSNameFilter)]{
offersExitNodeOrAppConnectorAndPeerHasAutogroupInternet,
}
// isPeerAPIDNSAllowed determines if any of the default or extension hooks
// permit PeerAPI DNS lookups for the current request.
// nameAllowed will never be nil when sourceAllowed is true, as required by
// [tailscale.com/net/dns/resolver.Resolver.HandlePeerDNSQuery], so it is not
// quite the same as a [DNSNameFilter].
func (h *peerAPIHandler) isPeerAPIDNSAllowed(r *http.Request) (sourceAllowed bool, nameAllowed func(string) bool) {
if !buildfeatures.HasDNS {
return false
return false, nil
}
if h.isSelf {
if h.IsSelfUntagged() {
// If the peer is owned by the same user, just allow it
// without further checks.
return true
}
b := h.ps.b
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
return true, h.allowExitNodeDNSProxyToServeName
}
if !h.remoteAddr.IsValid() {
// This should never be the case if the peerAPIHandler
// was wired up correctly, but just in case.
return false, nil
}
nameFilters := make([]DNSNameFilter, 0, len(HookReplyToDNSQueries))
for _, hook := range HookReplyToDNSQueries {
allow, allowedName := hook(h, r)
if !allow {
continue
}
if allowedName == nil {
// Allow all names by default (still subject to names restricted by
// the netmap).
return true, h.allowExitNodeDNSProxyToServeName
}
nameFilters = append(nameFilters, allowedName)
}
if len(nameFilters) == 0 {
return false, nil
}
return true, func(name string) bool {
// Always filter out names restricted by the netmap.
if !h.allowExitNodeDNSProxyToServeName(name) {
return false
}
// Now, only allow names permitted by at least one extension.
for _, f := range nameFilters {
if f(name) {
return true
}
}
return false
}
}
// exitNodeDNSFilterForTest overrides
// peerAPIHandler.allowExitNodeDNSProxyToServeName if set during test execution.
var exitNodeDNSFilterForTest func(name string) bool
func (h *peerAPIHandler) allowExitNodeDNSProxyToServeName(name string) bool {
if testenv.InTest() && exitNodeDNSFilterForTest != nil {
return exitNodeDNSFilterForTest(name)
}
return h.ps.b.allowExitNodeDNSProxyToServeName(name)
}
// offersExitNodeOrAppConnectorAndPeerHasAutogroupInternet is run as part of
// [HookReplyToDNSQueries] and handles our legacy PeerAPI DNS acceptance
// criteria:
// - When a node is advertising an exit node it will accept DNS queries
// from peers that have access to autogroup:internet.
// - When a node is advertising an app connector, it will accept DNS queries
// to peers that have access to a relevant app.
//
// Further details about how these are accomplished are in inline comments.
func offersExitNodeOrAppConnectorAndPeerHasAutogroupInternet(h PeerAPIHandler, _ *http.Request) (bool, DNSNameFilter) {
b := h.LocalBackend()
if !b.OfferingExitNode() && !b.OfferingAppConnector() {
// If we're not an exit node or app connector, this hook
// doesn't apply.
return false, nil
}
// Otherwise, we're an exit node but the peer is not us, so
// we need to check if they're allowed access to the internet.
// As peerapi bypasses wgengine/filter checks, we need to check
@@ -709,21 +829,21 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
// in LocalBackend).
f := b.currentNode().filter()
if f == nil {
return false
return false, nil
}
// Note: we check TCP here because the Filter type already had
// a CheckTCP method (for unit tests), but it's pretty
// arbitrary. DNS runs over TCP and UDP, so sure... we check
// TCP.
dstIP := netaddr.IPv4(0, 0, 0, 0)
remoteIP := h.remoteAddr.Addr()
remoteIP := h.RemoteAddr().Addr()
if remoteIP.Is6() {
// autogroup:internet for IPv6 is defined to start with 2000::/3,
// so use 2000::0 as the probe "the internet" address.
dstIP = netip.MustParseAddr("2000::")
}
verdict := f.CheckTCP(remoteIP, dstIP, 53)
return verdict == filter.Accept
return verdict == filter.Accept, nil
}
// handleDNSQuery implements a DoH server (RFC 8484) over the peerapi.
@@ -733,7 +853,8 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
http.Error(w, "DNS not wired up", http.StatusNotImplemented)
return
}
if !h.replyToDNSQueries() {
sourceAllowed, nameAllowed := h.isPeerAPIDNSAllowed(r)
if !sourceAllowed {
http.Error(w, "DNS access denied", http.StatusForbidden)
return
}
@@ -741,9 +862,14 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
q, publicError := dohQuery(r)
if publicError != "" && r.Method == "GET" {
if name := r.FormValue("q"); name != "" {
pretty = true
publicError = ""
q = dnsQueryForName(name, r.FormValue("t"))
nameQuery, err := dnsQueryForName(name, r.FormValue("t"))
if err != nil {
publicError = "invalid name in ‘q’ parameter"
} else {
pretty = true
publicError = ""
q = nameQuery
}
}
}
if publicError != "" {
@@ -757,7 +883,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.HandlePeerDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
res, err := h.ps.resolver.HandlePeerDNSQuery(ctx, q, h.remoteAddr, nameAllowed)
if err != nil {
h.logf("handleDNS fwd error: %v", err)
if err := ctx.Err(); err != nil {
@@ -822,7 +948,10 @@ func dohQuery(r *http.Request) (dnsQuery []byte, publicErr string) {
}
}
func dnsQueryForName(name, typStr string) []byte {
// dnsQueryForName builds a DNS query for name, which comes from the caller of
// the peerAPI DNS debug mode and is not otherwise validated. It returns an
// error if name does not fit in a DNS message.
func dnsQueryForName(name, typStr string) ([]byte, error) {
typ := dnsmessage.TypeA
switch strings.ToLower(typStr) {
case "aaaa":
@@ -830,22 +959,27 @@ func dnsQueryForName(name, typStr string) []byte {
case "txt":
typ = dnsmessage.TypeTXT
}
if !strings.HasSuffix(name, ".") {
name += "."
}
qname, err := dnsmessage.NewName(name)
if err != nil {
return nil, err
}
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{
OpCode: 0, // query
RecursionDesired: true,
ID: 1, // arbitrary, but 0 is rejected by some servers
})
if !strings.HasSuffix(name, ".") {
name += "."
}
b.StartQuestions()
b.Question(dnsmessage.Question{
Name: dnsmessage.MustNewName(name),
if err := b.Question(dnsmessage.Question{
Name: qname,
Type: typ,
Class: dnsmessage.ClassINET,
})
msg, _ := b.Finish()
return msg
}); err != nil {
return nil, err
}
return b.Finish()
}
func writePrettyDNSReply(w io.Writer, res []byte) (err error) {
@@ -1009,8 +1143,10 @@ func newFakePeerAPIListener(ip netip.Addr) net.Listener {
// for a given IP on port 1 (arbitrary) and can be Closed, but otherwise Accept
// just blocks forever until closed. The purpose of this is to let the rest
// of the LocalBackend/PeerAPI code run and think it's talking to the kernel,
// even if the kernel isn't cooperating (like on Android: Issue 4449, 4293, etc)
// or we lack permission to listen on a port. It's okay to not actually listen via
// even if the kernel isn't cooperating (like on Android: Issue 4449, 4293, etc),
// we lack permission to listen on a port, or a kernel listener would be
// reachable from off the tailnet (FreeBSD; see [peerAPIServer.skipKernelListener]).
// It's okay to not actually listen via
// the kernel because on almost all platforms (except iOS as of 2022-04-20) we
// also intercept incoming netstack TCP requests to our peerapi port and hand them over
// directly to peerapi, without involving the kernel. So this doesn't need to be
+3 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
@@ -11,7 +11,7 @@ import (
"strings"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/util/httpm"
)
@@ -34,7 +34,7 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request)
}
capsMap := h.PeerCaps()
driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
driveCaps, ok := capsMap[peercap.Taildrive]
if !ok {
h.logf("taildrive: not permitted")
http.Error(w, "taildrive not permitted", http.StatusForbidden)
+50
View File
@@ -0,0 +1,50 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
package ipnlocal
import (
"net"
"net/netip"
"tailscale.com/net/netns"
)
func init() {
initListenConfig = initListenConfigTun
}
// initListenConfigTun binds the peerapi listener to the tunnel interface
// with SO_BINDTODEVICE.
//
// Linux delivers a packet addressed to the node's own Tailscale IP via
// INPUT from any interface it arrives on (weak host model), so a
// LAN-adjacent attacker could otherwise complete a TCP handshake with
// the kernel-level listener, confirming that this machine's MAC
// address belongs to a given tailnet identity (the peerapi port is
// derived from the Tailscale IP). Binding the listener to the tunnel
// interface drops those handshakes at socket lookup. macOS and iOS
// already bind this listener to the tunnel interface with IP_BOUND_IF,
// and Windows is protected by its strong host model.
//
// No legitimate traffic is lost. Connections to the node's Tailscale
// IP from the local host are delivered via the tunnel interface, peer
// connections arrive that way in builds without userspace netstack,
// and with netstack compiled in (the usual case) peer connections are
// terminated in userspace and never reach the kernel listener at all.
// In netstack mode there is no tunnel interface and the listener binds
// all addresses; the only connections that reach it are loopback dials
// from netstack and from the local host, so loopback is the right
// device there.
func initListenConfigTun(config *net.ListenConfig, addr netip.Addr, tunIfIndex int) error {
device := "lo"
if tunIfIndex != 0 {
iface, err := net.InterfaceByIndex(tunIfIndex)
if err == nil {
device = iface.Name
}
}
return netns.SetListenConfigInterfaceName(config, device)
}
+7 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_macext && (darwin || ios)
@@ -6,6 +6,7 @@
package ipnlocal
import (
"fmt"
"net"
"net/netip"
@@ -20,5 +21,10 @@ func init() {
// through the iOS/macOS Network/System Extension (Packet Tunnel
// Provider) sandbox.
func initListenConfigNetworkExtension(nc *net.ListenConfig, ip netip.Addr, ifaceIndex int) error {
// A zero ifaceIndex is invalid for peerapi. A zero value will not get us
// out of the network sandbox. Caller should log and retry.
if ifaceIndex == 0 {
return fmt.Errorf("peerapi: cannot listen on %s with ifaceIndex 0", ip)
}
return netns.SetListenConfigInterfaceIndex(nc, ifaceIndex)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
+21 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -21,6 +21,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
@@ -64,6 +65,10 @@ type profileManager struct {
// Override for key.NewEmptyHardwareAttestationKey used for testing.
newEmptyHardwareAttestationKey func() (key.HardwareAttestationKey, error)
// clock supplies the current time when stamping LoginProfile.Created.
// Tests substitute a fake clock to make creation timestamps deterministic.
clock tstime.DefaultClock
}
// SetExtensionHost sets the [ExtensionHost] for the [profileManager].
@@ -189,7 +194,7 @@ func (pm *profileManager) SwitchToProfile(profile ipn.LoginProfileView) (cp ipn.
f(pm.currentProfile, pm.prefs, false)
}
// Do not call pm.extHost.NotifyProfileChange here; it is invoked in
// [LocalBackend.resetForProfileChangeLockedOnEntry] after the netmap reset.
// [LocalBackend.resetForProfileChangeLocked] after the netmap reset.
// TODO(nickkhyl): Consider moving it here (or into the stateChangeCb handler
// in [LocalBackend]) once the profile/node state, including the netmap,
// is actually tied to the current profile.
@@ -250,7 +255,14 @@ func (pm *profileManager) allProfilesFor(uid ipn.WindowsUserID) []ipn.LoginProfi
}
}
slices.SortFunc(out, func(a, b ipn.LoginProfileView) int {
return cmp.Compare(a.Name(), b.Name())
// Legacy (zero Created) first, then stamped oldest-first.
if c := a.Created().Compare(b.Created()); c != 0 {
return c
}
if c := cmp.Compare(a.Name(), b.Name()); c != 0 {
return c
}
return cmp.Compare(a.NetworkProfile().DomainName, b.NetworkProfile().DomainName)
})
return out
}
@@ -274,7 +286,7 @@ func (pm *profileManager) matchingProfiles(uid ipn.WindowsUserID, f func(ipn.Log
func (pm *profileManager) findMatchingProfiles(uid ipn.WindowsUserID, prefs ipn.PrefsView) []ipn.LoginProfileView {
return pm.matchingProfiles(uid, func(p ipn.LoginProfileView) bool {
return p.ControlURL() == prefs.ControlURL() &&
(p.UserProfile().ID == prefs.Persist().UserProfile().ID ||
(p.UserProfile().ID() == prefs.Persist().UserProfile().ID() ||
p.NodeID() == prefs.Persist().NodeID())
})
}
@@ -337,7 +349,7 @@ func (pm *profileManager) setUnattendedModeAsConfigured() error {
// across user switches to disambiguate the same account but a different tailnet.
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile) error {
cp := pm.currentProfile
if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName == "" {
if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName() == "" {
// We don't know anything about this profile, so ignore it for now.
return pm.setProfilePrefsNoPermCheck(pm.currentProfile, prefsIn.AsStruct().View())
}
@@ -369,7 +381,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
// WantRunning and possibly other fields. This may not be the desired behavior.
//
// Additionally, LocalBackend doesn't treat it as a proper profile switch, meaning that
// [LocalBackend.resetForProfileChangeLockedOnEntry] is not called and certain
// [LocalBackend.resetForProfileChangeLocked] is not called and certain
// node/profile-specific state may not be reset as expected.
//
// However, [profileManager] notifies [ipnext.Extension]s about the profile change,
@@ -410,9 +422,10 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
// and it hasn't been persisted yet. We'll generate both an ID and [ipn.StateKey]
// once the information is available and needs to be persisted.
if lp.ID == "" {
if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName != "" {
if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName() != "" {
// Generate an ID and [ipn.StateKey] now that we have the node info.
lp.ID, lp.Key = newUnusedID(pm.knownProfiles)
lp.Created = pm.clock.Now()
}
// Set the current user as the profile owner, unless the current user ID does
@@ -425,7 +438,7 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
var up tailcfg.UserProfile
if persist := prefsIn.Persist(); persist.Valid() {
up = persist.UserProfile()
up = *persist.UserProfile().AsStruct()
if up.DisplayName == "" {
up.DisplayName = up.LoginName
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
+198
View File
@@ -0,0 +1,198 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"net/netip"
"strings"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
"tailscale.com/wgengine"
"tailscale.com/wgengine/wgcfg"
)
// lookupPeerByIP returns the node public key for the peer that should
// handle traffic to the given IP address. It is installed as the
// [wgengine.Engine.SetPeerByIPPacketFunc] callback: exact node
// addresses hit the nodeByAddr fast path, and subnet routes and
// exit-node default routes fall back to the RouteManager's outbound
// table, so it stays correct under incremental netmap deltas.
//
// It is called by wireguard-go on every outbound packet (not cached),
// so it must be fast.
func (b *LocalBackend) lookupPeerByIP(ip netip.Addr) (key.NodePublic, bool) {
nb := b.currentNode()
if nid, ok := nb.NodeByAddr(ip); ok {
peer, ok := nb.NodeByID(nid)
if !ok {
return key.NodePublic{}, false
}
return peer.Key(), true
}
if pr, ok := nb.routeMgr.Outbound().Lookup(ip); ok {
return pr.Key, true
}
return key.NodePublic{}, false
}
// peerConfig returns the WireGuard configuration for the peer with the given
// public key, or ok=false if the peer is unknown (or currently routable via no
// prefix at all). It is installed as the
// [wgengine.Engine.SetPeerConfigFunc] callback, backing wireguard-go's
// lazy peer creation and per-delta peer sync.
func (b *LocalBackend) peerConfig(k key.NodePublic) (_ wgcfg.PeerConfig, ok bool) {
ips, ok := b.currentNode().PeerAllowedIPs(k)
return wgcfg.PeerConfig{AllowedIPs: ips}, ok
}
// resolveMagicDNS resolves a MagicDNS hostname to the owning node's IP
// address, respecting the requested network address family ("tcp4",
// "tcp6", "tcp", etc.). It accepts peer FQDNs ("foo.tail-scale.ts.net"),
// short names ("foo"), and DNS.ExtraRecords entries (service VIPs).
// The hostname must be lowercase with no trailing dot. It is installed
// as the [tsdial.Dialer.SetResolveMagicDNS] callback.
func (b *LocalBackend) resolveMagicDNS(hostname, network string) (_ netip.Addr, ok bool) {
nb := b.currentNode()
if nid, ok := nb.NodeByName(hostname); ok {
n, ok := nb.NodeByID(nid)
if !ok {
b.logf("[unexpected] resolveMagicDNS: NodeByName(%q) returned node %v but NodeByID failed", hostname, nid)
return netip.Addr{}, false
}
if ip, ok := nodeAddrForNetwork(n, network); ok {
return ip, true
}
return netip.Addr{}, false
}
if ip, ok := nb.ExtraDNSByName(hostname); ok && addrFamilyMatch(ip, network) {
return ip, true
}
return netip.Addr{}, false
}
// magicDNSHosts implements [resolver.MagicDNSHosts] on top of the
// current nodeBackend's live node indexes, so the quad-100 resolver
// pulls each MagicDNS answer on demand rather than LocalBackend
// pushing a Hosts map of every node into it on every netmap change.
// It is installed once at LocalBackend construction; going through
// currentNode makes profile switches take effect immediately.
// Short names ("foo") are only resolved if MagicDNS is enabled
// for the current tailnet.
type magicDNSHosts struct{ b *LocalBackend }
func (m magicDNSHosts) LookupHost(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) {
if !buildfeatures.HasDNS {
return nil, false
}
return m.b.currentNode().magicDNSHostAddrs(fqdn)
}
func (m magicDNSHosts) LookupPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) {
if !buildfeatures.HasDNS {
return "", false
}
return m.b.currentNode().magicDNSPTR(ip)
}
func (m magicDNSHosts) SubdomainHost(fqdn dnsname.FQDN) bool {
if !buildfeatures.HasDNS {
return false
}
return m.b.currentNode().magicDNSSubdomainHost(fqdn)
}
var _ resolver.MagicDNSHosts = magicDNSHosts{}
// nodeAddrForNetwork returns the best address from n for the given
// network ("tcp", "tcp4", "tcp6", "udp", "udp4", "udp6"). For
// unqualified networks ("tcp", "udp"), it prefers IPv4.
func nodeAddrForNetwork(n tailcfg.NodeView, network string) (_ netip.Addr, ok bool) {
addrs := n.Addresses()
if addrs.Len() == 0 {
return netip.Addr{}, false
}
want4 := strings.HasSuffix(network, "4")
want6 := strings.HasSuffix(network, "6")
var v6 netip.Addr
for _, pfx := range addrs.All() {
ip := pfx.Addr()
if want4 && ip.Is4() {
return ip, true
}
if want6 && ip.Is6() {
return ip, true
}
if !want4 && !want6 {
if ip.Is4() {
return ip, true
}
if !v6.IsValid() {
v6 = ip
}
}
}
if v6.IsValid() {
return v6, true
}
return netip.Addr{}, false
}
// addrFamilyMatch reports whether ip is compatible with the requested
// network address family.
func addrFamilyMatch(ip netip.Addr, network string) bool {
if strings.HasSuffix(network, "4") {
return ip.Is4()
}
if strings.HasSuffix(network, "6") {
return ip.Is6()
}
return true
}
// PeerForIP returns which peer is responsible for a given IP address.
// Despite the name, it can also return the self node (with IsSelf set).
// It handles both Tailscale IPs (returning the owning peer or self) and
// non-Tailscale addresses like subnet-routed IPs or exit-node global
// internet IPs (returning whichever peer would route that traffic).
// It is installed as the [wgengine.Engine.SetPeerForIPFunc] callback,
// serving the engine's internal cold-path lookups (Ping, TSMP, pendopen
// diagnostics).
func (b *LocalBackend) PeerForIP(ip netip.Addr) (_ wgengine.PeerForIP, ok bool) {
nb := b.currentNode()
if tsaddr.IsTailscaleIP(ip) {
if nid, ok := nb.NodeByAddr(ip); ok {
n, ok := nb.NodeByID(nid)
if !ok {
b.logf("[unexpected] peerForIP: NodeByAddr(%v) returned node %v but NodeByID failed", ip, nid)
return wgengine.PeerForIP{}, false
}
self := nb.Self()
return wgengine.PeerForIP{
Node: n,
IsSelf: self.Valid() && self.ID() == nid,
Route: netip.PrefixFrom(ip, ip.BitLen()),
}, true
}
}
route, pr, ok := nb.routeMgr.Outbound().LookupPrefixLPM(netip.PrefixFrom(ip, ip.BitLen()))
if !ok {
return wgengine.PeerForIP{}, false
}
nid, ok := nb.NodeByKey(pr.Key)
if !ok {
return wgengine.PeerForIP{}, false
}
n, ok := nb.NodeByID(nid)
if !ok {
return wgengine.PeerForIP{}, false
}
return wgengine.PeerForIP{Node: n, Route: route}, true
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_routecheck
package ipnlocal
import "tailscale.com/tailcfg"
func isRouteCheckEnabled(self tailcfg.NodeView) bool {
return false
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package ipnlocal
import (
"tailscale.com/net/routecheck"
"tailscale.com/tailcfg"
)
func isRouteCheckEnabled(self tailcfg.NodeView) bool {
return routecheck.IsEnabled(self)
}
+199 -99
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
@@ -35,11 +35,13 @@ import (
"github.com/pires/go-proxyproto"
"go4.org/mem"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/net/netmon"
"tailscale.com/net/netutil"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/types/views"
@@ -48,6 +50,7 @@ import (
"tailscale.com/util/ctxkey"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
"tailscale.com/util/usermetric"
"tailscale.com/version"
)
@@ -91,7 +94,7 @@ type serveHTTPContext struct {
// provides funnel-specific context, nil if not funneled
Funnel *funnelFlow
// AppCapabilities lists all PeerCapabilities that should be forwarded by serve
AppCapabilities views.Slice[tailcfg.PeerCapability]
AppCapabilities views.Slice[peercap.Cap]
}
// funnelFlow represents a funneled connection initiated via IngressPeer
@@ -121,8 +124,22 @@ type localListener struct {
handler func(net.Conn) error // handler for inbound connections
closeListener syncs.AtomicValue[func() error] // Listener's Close method, if any
// allowAllInterfaces, if set, stops the listener from being bound to the
// Tailscale interface, so it answers traffic arriving on any interface.
// Only serve listeners set it, and only when serveAllowAllInterfaces is
// set; the web client listener always stays bound.
allowAllInterfaces bool
}
// serveAllowAllInterfaces, when set, stops serve's kernel listeners from
// being bound to the Tailscale interface, so they answer traffic arriving on
// any interface. It applies only to serve listeners, not the web client
// listener. This re-exposes serve to the local network; it is set only by the
// Kubernetes operator's experimental cluster-traffic ingress feature, which
// forwards cluster traffic to the node's Tailscale IP via another interface.
var serveAllowAllInterfaces = envknob.RegisterBool("TS_SERVE_ALLOW_ALL_INTERFACES")
func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *localListener {
ctx, cancel := context.WithCancel(ctx)
return &localListener{
@@ -132,6 +149,8 @@ func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort,
cancel: cancel,
logf: logf,
allowAllInterfaces: serveAllowAllInterfaces(),
handler: func(conn net.Conn) error {
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
handler := b.tcpHandlerForServe(ap.Port(), srcAddr, nil)
@@ -166,7 +185,7 @@ func (s *localListener) Run() {
ipStr := ip.String()
var lc net.ListenConfig
if initListenConfig != nil {
if initListenConfig != nil && !s.allowAllInterfaces {
ifIndex, err := netmon.TailscaleInterfaceIndex()
if err != nil {
s.logf("localListener failed to get Tailscale interface index %v, backing off: %v", s.ap, err)
@@ -220,14 +239,13 @@ func (s *localListener) Run() {
s.closeListener.Store(ln.Close)
s.logf("listening on %v", s.ap)
// handleListenersAccept always returns a non-nil error.
err = s.handleListenersAccept(ln)
if s.ctx.Err() != nil {
// context canceled, we're done
return
}
if err != nil {
s.logf("localListener accept error, retrying: %v", err)
}
s.logf("localListener accept error, retrying: %v", err)
}
}
@@ -276,7 +294,7 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
}
}
nm := b.NetMap()
nm := b.NetMapNoPeers()
if nm == nil {
b.logf("netMap is nil")
return
@@ -333,7 +351,7 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
}
nm := b.NetMap()
nm := b.NetMapNoPeers()
if nm == nil {
return errors.New("netMap is nil")
}
@@ -534,6 +552,56 @@ func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcf
return servicesList
}
type serviceMeteredConn struct {
net.Conn
inbound, outbound *usermetric.MultiLabelMap[serveLabels]
key serveLabels
}
func (c *serviceMeteredConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.inbound.Add(c.key, int64(n))
}
return n, err
}
func (c *serviceMeteredConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
if n > 0 {
c.outbound.Add(c.key, int64(n))
}
return n, err
}
// CloseWrite forwards a write-half close to the underlying conn. We only embed
// the net.Conn interface, which would otherwise hide the underlying conn's
// CloseWrite; net/http's server relies on it (closeWriteAndWait) to send a FIN
// and drain gracefully when a connection won't be reused, avoiding a truncating
// RST on the final response.
func (c *serviceMeteredConn) CloseWrite() error {
if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok {
return cw.CloseWrite()
}
return nil
}
// meteredConnForService wraps c to count peer bytes against the per-Service
// Serve counters. The per-Service series is never evicted, so it leaks
// (intentionally) until tailscaled exits.
func (b *LocalBackend) meteredConnForService(c net.Conn, svc tailcfg.ServiceName) net.Conn {
// Plain (non-Service) serve passes an empty svc; don't meter it.
if svc == "" || b.metrics.serveBytesInbound == nil || b.metrics.serveBytesOutbound == nil {
return c
}
return &serviceMeteredConn{
Conn: c,
inbound: b.metrics.serveBytesInbound,
outbound: b.metrics.serveBytesOutbound,
key: serveLabels{Service: svc.String()},
}
}
// tcpHandlerForVIPService returns a handler for a TCP connection to a VIP service
// that is being served via the ipn.ServeConfig. It returns nil if the destination
// address is not a VIP service or if the VIP service does not have a TCP handler set.
@@ -560,68 +628,14 @@ func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort)
return nil
}
if tcph.HTTPS() || tcph.HTTP() {
hs := &http.Server{
Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
SrcAddr: srcAddr,
ForVIPService: dstSvc,
DestPort: dport,
})
},
}
if tcph.HTTPS() {
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
// in the hostname. How to store the TLS cert is still being discussed.
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport, dstSvc),
}
return func(c net.Conn) error {
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
}
}
return func(c net.Conn) error {
return hs.Serve(netutil.NewOneConnListener(c, nil))
}
}
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
cancel()
if err != nil {
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
return nil
}
defer backConn.Close()
if sni := tcph.TerminateTLS(); sni != "" {
conn = tls.Server(conn, &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
},
})
}
return b.forwardTCPWithProxyProtocol(conn, backConn, tcph.ProxyProtocol(), srcAddr, dport, backDst)
}
}
return nil
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
// in the hostname. How to store the TLS cert is still being discussed.
return b.tcpHandlerForServeTCP(tcph, dport, srcAddr, &serveHTTPContext{
SrcAddr: srcAddr,
ForVIPService: dstSvc,
DestPort: dport,
}, dstSvc)
}
// tcpHandlerForServe returns a handler for a TCP connection to be served via
@@ -641,27 +655,32 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
return nil
}
return b.tcpHandlerForServeTCP(tcph, dport, srcAddr, &serveHTTPContext{
Funnel: f,
SrcAddr: srcAddr,
DestPort: dport,
}, "")
}
func (b *LocalBackend) tcpHandlerForServeTCP(tcph ipn.TCPPortHandlerView, dport uint16, srcAddr netip.AddrPort, httpCtx *serveHTTPContext, forVIPService tailcfg.ServiceName) func(net.Conn) error {
if tcph.HTTPS() || tcph.HTTP() {
hs := &http.Server{
Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
Funnel: f,
SrcAddr: srcAddr,
DestPort: dport,
})
c := *httpCtx
return serveHTTPContextKey.WithValue(context.Background(), &c)
},
}
if tcph.HTTPS() {
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport, ""),
}
hs.TLSConfig = b.serveTLSConfig(b.getTLSServeCertForPort(dport, forVIPService), serveTLSNextProtos())
return func(c net.Conn) error {
c = b.meteredConnForService(c, forVIPService)
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
}
}
return func(c net.Conn) error {
c = b.meteredConnForService(c, forVIPService)
return hs.Serve(netutil.NewOneConnListener(c, nil))
}
}
@@ -669,8 +688,16 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
conn = b.meteredConnForService(conn, forVIPService)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
var backConn net.Conn
var err error
if socketPath, ok := strings.CutPrefix(backDst, "unix:"); ok {
var d net.Dialer
backConn, err = d.DialContext(ctx, "unix", socketPath)
} else {
backConn, err = b.dialer.SystemDial(ctx, "tcp", backDst)
}
cancel()
if err != nil {
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
@@ -678,21 +705,22 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
}
defer backConn.Close()
if sni := tcph.TerminateTLS(); sni != "" {
conn = tls.Server(conn, &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
},
})
conn = tls.Server(conn, b.serveTLSConfig(func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if cert, ok := b.getACMETLSALPNCert(hi); ok {
return cert, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}, nil))
}
// TODO(bradfitz): do the RegisterIPPortIdentity and
@@ -710,7 +738,13 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
func (b *LocalBackend) forwardTCPWithProxyProtocol(conn, backConn net.Conn, proxyProtoVer int, srcAddr netip.AddrPort, dport uint16, backDst string) error {
var proxyHeader []byte
if proxyProtoVer > 0 {
backAddr := backConn.RemoteAddr().(*net.TCPAddr)
// PROXY protocol requires a valid TCP destination address.
// For Unix socket backends, RemoteAddr is *net.UnixAddr;
// the CLI rejects this combination, but guard here as well.
backAddr, ok := backConn.RemoteAddr().(*net.TCPAddr)
if !ok {
return fmt.Errorf("PROXY protocol is not supported with non-TCP backend %s", backDst)
}
// We always want to format the PROXY protocol header based on
// the IPv4 or IPv6-ness of the client. The SourceAddr and
@@ -814,6 +848,15 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
return h, r.URL.Path, true
}
pth := path.Clean(r.URL.Path)
// A well-formed origin-form request path is absolute. Malformed request
// targets — "*" (e.g. "GET *") and "" (e.g. "CONNECT" authority-form),
// clean to "*" and "." respectively. Those are path.Dir fixed points that
// never equal "/" and match no mount, so without this guard the loop below
// would spin forever on one CPU core (a remote DoS via serve, or via funnel
// from the internet).
if !strings.HasPrefix(pth, "/") {
return z, "", false
}
for {
withSlash := pth + "/"
if h, ok := wsc.Handlers().GetOk(withSlash); ok {
@@ -825,7 +868,13 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
if pth == "/" {
return z, "", false
}
pth = path.Dir(pth)
// Belt-and-suspenders with the absolute-path check above: stop if
// path.Dir stops shrinking rather than assuming it always reaches "/".
if parent := path.Dir(pth); parent != pth {
pth = parent
} else {
return z, "", false
}
}
}
@@ -835,8 +884,8 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, err
targetURL, insecure := expandProxyArg(backend)
// Handle unix: scheme specially
if strings.HasPrefix(targetURL, "unix:") {
socketPath := strings.TrimPrefix(targetURL, "unix:")
if after, ok := strings.CutPrefix(targetURL, "unix:"); ok {
socketPath := after
if socketPath == "" {
return nil, fmt.Errorf("empty unix socket path")
}
@@ -969,6 +1018,8 @@ func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// to the backend. The Transport gets created lazily, at most once.
func (rp *reverseProxy) getTransport() *http.Transport {
return rp.httpTransport.Get(func() *http.Transport {
// Zero preserves http.Transport's default MaxIdleConnsPerHost value.
maxIdleConnsPerHost, _ := envknob.LookupInt("TS_DEBUG_SERVE_MAX_IDLE_CONNS_PER_HOST")
dial := rp.lb.dialer.SystemDial
if rp.socketPath != "" {
dial = func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -985,6 +1036,7 @@ func (rp *reverseProxy) getTransport() *http.Transport {
// Values for the following parameters have been copied from http.DefaultTransport.
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
@@ -1106,7 +1158,7 @@ func (b *LocalBackend) addAppCapabilitiesHeader(r *httputil.ProxyRequest) error
return nil
}
peerCapsFiltered := make(map[tailcfg.PeerCapability][]tailcfg.RawMessage, acceptCaps.Len())
peerCapsFiltered := make(map[peercap.Cap][]tailcfg.RawMessage, acceptCaps.Len())
for _, cap := range acceptCaps.AsSlice() {
if peerCaps.HasCapability(cap) {
peerCapsFiltered[cap] = peerCaps[cap]
@@ -1311,6 +1363,9 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
if cert, ok := b.getACMETLSALPNCert(hi); ok {
return cert, nil
}
_, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
if !ok {
return nil, errors.New("no webserver configured for name/port")
@@ -1330,6 +1385,48 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
}
}
// serveTLSConfig returns the TLS configuration used by Serve and TCP-forwarded
// TLS listeners. nextProtos is the ALPN list to advertise for normal
// handshakes; it should be serveTLSNextProtos for HTTPS Serve and nil for
// TLS-terminated TCP forwarding where we don't know the backend protocol.
// During an ACME tls-alpn-01 renewal, GetConfigForClient clones the base config
// and temporarily prepends acme-tls/1, but only for the exact SNI with a pending
// challenge certificate. This keeps ordinary Serve traffic from advertising
// ACME support and lets Go's TLS stack negotiate the challenge protocol before
// GetCertificate is called.
func (b *LocalBackend) serveTLSConfig(getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), nextProtos []string) *tls.Config {
base := &tls.Config{
GetCertificate: getCert,
NextProtos: nextProtos,
}
base.GetConfigForClient = func(hi *tls.ClientHelloInfo) (*tls.Config, error) {
var nextProtos []string
if proto, ok := b.getACMETLSALPNProto(hi); ok {
b.logf("serve: accepting ACME tls-alpn-01 challenge for %q", hi.ServerName)
nextProtos = append(nextProtos, proto)
}
if len(nextProtos) == 0 {
return nil, nil
}
cfg := base.Clone()
cfg.NextProtos = append(nextProtos, base.NextProtos...)
return cfg, nil
}
return base
}
// HasFunnelForHostPort reports whether the LocalBackend's serve config
// has Funnel enabled for host:port.
func (b *LocalBackend) HasFunnelForHostPort(host string, port uint16) bool {
b.mu.Lock()
defer b.mu.Unlock()
if !b.serveConfig.Valid() {
return false
}
hp := ipn.HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
return b.serveConfig.HasFunnelForTarget(hp)
}
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
// backend specified in serveConfig. It expects serveConfig to be valid and
// up-to-date, so should be called after reloadServeConfigLocked.
@@ -1551,6 +1648,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if err != nil {
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
b.updateCertRefreshLoopLocked()
return
}
if b.lastServeConfJSON.Equal(mem.B(confj)) {
@@ -1561,6 +1659,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if err := json.Unmarshal(confj, &conf); err != nil {
b.logf("invalid ServeConfig %q in StateStore: %v", confKey, err)
b.serveConfig = ipn.ServeConfigView{}
b.updateCertRefreshLoopLocked()
return
}
@@ -1571,6 +1670,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
})
b.serveConfig = conf.View()
b.updateCertRefreshLoopLocked()
}
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[tailcfg.ServiceName][]uint16) {
+4 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_serve
@@ -28,6 +28,9 @@ type funnelFlow = struct{}
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
func (*LocalBackend) HasFunnelForHostPort(host string, port uint16) bool {
return false
}
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
return nil
-234
View File
@@ -1,234 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh
package ipnlocal
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"go4.org/mem"
"golang.org/x/crypto/ssh"
"tailscale.com/tailcfg"
"tailscale.com/util/lineiter"
"tailscale.com/util/mak"
)
// keyTypes are the SSH key types that we either try to read from the
// system's OpenSSH keys or try to generate for ourselves when not
// running as root.
var keyTypes = []string{"rsa", "ecdsa", "ed25519"}
// getSSHUsernames discovers and returns the list of usernames that are
// potential Tailscale SSH user targets.
//
// Invariant: must not be called with b.mu held.
func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
res := new(tailcfg.C2NSSHUsernamesResponse)
if !b.tailscaleSSHEnabled() {
return res, nil
}
max := 10
if req != nil && req.Max != 0 {
max = req.Max
}
add := func(u string) {
if req != nil && req.Exclude[u] {
return
}
switch u {
case "nobody", "daemon", "sync":
return
}
if slices.Contains(res.Usernames, u) {
return
}
if len(res.Usernames) > max {
// Enough for a hint.
return
}
res.Usernames = append(res.Usernames, u)
}
if opUser := b.operatorUserName(); opUser != "" {
add(opUser)
}
// Check popular usernames and see if they exist with a real shell.
switch runtime.GOOS {
case "darwin":
out, err := exec.Command("dscl", ".", "list", "/Users").Output()
if err != nil {
return nil, err
}
for line := range lineiter.Bytes(out) {
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '_' {
continue
}
add(string(line))
}
default:
for lr := range lineiter.File("/etc/passwd") {
line, err := lr.Value()
if err != nil {
break
}
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '#' || line[0] == '_' {
continue
}
if mem.HasSuffix(mem.B(line), mem.S("/nologin")) ||
mem.HasSuffix(mem.B(line), mem.S("/false")) {
continue
}
colon := bytes.IndexByte(line, ':')
if colon != -1 {
add(string(line[:colon]))
}
}
}
return res, nil
}
func (b *LocalBackend) GetSSH_HostKeys() (keys []ssh.Signer, err error) {
var existing map[string]ssh.Signer
if os.Geteuid() == 0 {
existing = b.getSystemSSH_HostKeys()
}
return b.getTailscaleSSH_HostKeys(existing)
}
// getTailscaleSSH_HostKeys returns the three (rsa, ecdsa, ed25519) SSH host
// keys, reusing the provided ones in existing if present in the map.
func (b *LocalBackend) getTailscaleSSH_HostKeys(existing map[string]ssh.Signer) (keys []ssh.Signer, err error) {
var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir.
for _, typ := range keyTypes {
if s, ok := existing[typ]; ok {
keys = append(keys, s)
continue
}
if keyDir == "" {
root := b.TailscaleVarRoot()
if root == "" {
return nil, errors.New("no var root for ssh keys")
}
keyDir = filepath.Join(root, "ssh")
if err := os.MkdirAll(keyDir, 0700); err != nil {
return nil, err
}
}
hostKey, err := b.hostKeyFileOrCreate(keyDir, typ)
if err != nil {
return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err)
}
signer, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err)
}
keys = append(keys, signer)
}
return keys, nil
}
var keyGenMu sync.Mutex
func (b *LocalBackend) hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
keyGenMu.Lock()
defer keyGenMu.Unlock()
path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
v, err := os.ReadFile(path)
if err == nil {
return v, nil
}
if !os.IsNotExist(err) {
return nil, err
}
var priv any
switch typ {
default:
return nil, fmt.Errorf("unsupported key type %q", typ)
case "ed25519":
_, priv, err = ed25519.GenerateKey(rand.Reader)
case "ecdsa":
// curve is arbitrary. We pick whatever will at
// least pacify clients as the actual encryption
// doesn't matter: it's all over WireGuard anyway.
curve := elliptic.P256()
priv, err = ecdsa.GenerateKey(curve, rand.Reader)
case "rsa":
// keySize is arbitrary. We pick whatever will at
// least pacify clients as the actual encryption
// doesn't matter: it's all over WireGuard anyway.
const keySize = 2048
priv, err = rsa.GenerateKey(rand.Reader, keySize)
}
if err != nil {
return nil, err
}
mk, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
err = os.WriteFile(path, pemGen, 0700)
return pemGen, err
}
func (b *LocalBackend) getSystemSSH_HostKeys() (ret map[string]ssh.Signer) {
for _, typ := range keyTypes {
filename := "/etc/ssh/ssh_host_" + typ + "_key"
hostKey, err := os.ReadFile(filename)
if err != nil || len(bytes.TrimSpace(hostKey)) == 0 {
continue
}
signer, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
b.logf("warning: error reading host key %s: %v (generating one instead)", filename, err)
continue
}
mak.Set(&ret, typ, signer)
}
return ret
}
func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) {
signers, err := b.GetSSH_HostKeys()
if err != nil {
return nil, err
}
var keyStrings []string
for _, signer := range signers {
keyStrings = append(keyStrings, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))))
}
return keyStrings, nil
}
// tailscaleSSHEnabled reports whether Tailscale SSH is currently enabled based
// on prefs. It returns false if there are no prefs set.
func (b *LocalBackend) tailscaleSSHEnabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
p := b.pm.CurrentPrefs()
return p.Valid() && p.RunSSH()
}
-20
View File
@@ -1,20 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_ssh || ios || android || (!linux && !darwin && !freebsd && !openbsd && !plan9)
package ipnlocal
import (
"errors"
"tailscale.com/tailcfg"
)
func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) {
return nil, nil
}
func (b *LocalBackend) getSSHUsernames(*tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
return nil, errors.New("not implemented")
}
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
@@ -23,10 +23,12 @@ import (
"slices"
"time"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tka"
@@ -36,27 +38,46 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/tkatype"
"tailscale.com/util/httpbody"
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/util/testenv"
)
// TODO(tom): RPC retry/backoff was broken and has been removed. Fix?
var (
errMissingNetmap = errors.New("missing netmap: verify that you are logged in")
errNetworkLockNotActive = errors.New("network-lock is not active")
tkaCompactionDefaults = tka.CompactionOptions{
MinChain: 24, // Keep at minimum 24 AUMs since head.
MinAge: 14 * 24 * time.Hour, // Keep 2 weeks of AUMs.
}
errTailnetLockNotActive = errors.New("tailnet-lock is not active")
)
// tkaMaxResponseBodySize is the response body cap for tka RPCs whose
// responses can carry per-node key signatures or missing AUMs. At 100,000
// peers and a few hundred bytes per signature, those responses reach tens
// of MB, so the cap sits far above that while still bounding what a
// malicious control server can make us buffer.
const tkaMaxResponseBodySize = 512 << 20
// IsTailnetLockNotActive reports whether the given error indicates that
// tailnet-lock is not active. Stop-gap for feature/tailnetlock to check this
// until all of this is code is moved to the feature.
func IsTailnetLockNotActive(err error) bool {
return errors.Is(err, errTailnetLockNotActive)
}
// Deprecated: use [IsTailnetLockNotActive] instead.
func IsNetworkLockNotActive(err error) bool {
return IsTailnetLockNotActive(err)
}
type tkaState struct {
profile ipn.ProfileID
authority *tka.Authority
storage tka.CompactableChonk
filtered []ipnstate.TKAPeer
// filtered tracks peers that were removed from the netmap because
// they failed tailnet lock signature verification.
filtered map[tailcfg.NodeID]ipnstate.TKAPeer
}
func (b *LocalBackend) initTKALocked() error {
@@ -76,13 +97,13 @@ func (b *LocalBackend) initTKALocked() error {
root := b.TailscaleVarRoot()
if root == "" {
b.tka = nil
b.logf("cannot fetch existing TKA state; no state directory for network-lock")
b.logf("cannot fetch existing TKA state; no state directory for tailnet-lock")
return nil
}
chonkDir := b.chonkPathLocked()
if _, err := os.Stat(chonkDir); err == nil {
// The directory exists, which means network-lock has been initialized.
// The directory exists, which means tailnet-lock has been initialized.
storage, err := tka.ChonkDir(chonkDir)
if err != nil {
return fmt.Errorf("opening tailchonk: %v", err)
@@ -92,31 +113,75 @@ func (b *LocalBackend) initTKALocked() error {
return fmt.Errorf("initializing tka: %v", err)
}
if err := authority.Compact(storage, tkaCompactionDefaults); err != nil {
b.logf("tka compaction failed: %v", err)
}
b.tka = &tkaState{
profile: cp.ID(),
authority: authority,
storage: storage,
}
b.logf("tka initialized at head %x", authority.Head())
b.logf("tka initialized at head %s", authority.Head())
}
return nil
}
// noNetworkLockStateDirWarnable is a Warnable to warn the user that Tailnet Lock data
// noTailnetLockStateDirWarnable is a Warnable to warn the user that Tailnet Lock data
// (in particular, the list of AUMs in the TKA state) is being stored in memory and will
// be lost when tailscaled restarts.
var noNetworkLockStateDirWarnable = health.Register(&health.Warnable{
var noTailnetLockStateDirWarnable = health.Register(&health.Warnable{
Code: "no-tailnet-lock-state-dir",
Title: "No statedir for Tailnet Lock",
Severity: health.SeverityMedium,
Text: health.StaticMessage(healthmsg.InMemoryTailnetLockState),
})
// tkaFilterDeltaMutsLocked drops any [netmap.NodeMutationUpsert] in muts
// whose peer would fail tailnet lock signature verification, replacing
// each such upsert with a [netmap.NodeMutationRemove] for the same node
// ID. This matches the semantics of [tkaFilterNetmapLocked] on a full
// netmap: an unsigned (or invalidly-signed) peer must not land in
// [nodeBackend.peers], and a previously-signed peer at the same node ID
// must be evicted if the latest state from control fails verification.
//
// If tailnet lock is not active on this node (b.tka == nil) muts is
// returned unchanged. The returned slice may share backing storage with
// the input.
//
// b.mu must be held.
func (b *LocalBackend) tkaFilterDeltaMutsLocked(muts []netmap.NodeMutation) []netmap.NodeMutation {
if b.tka == nil {
return muts
}
if envknob.TKASkipSignatureCheck() {
return muts
}
for i, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationUpsert:
n := m.Node
if n.UnsignedPeerAPIOnly() {
continue
}
var why string
if n.KeySignature().Len() == 0 {
why = "missing signature"
} else if err := b.tka.authority.NodeKeyAuthorized(n.Key(), n.KeySignature().AsSlice()); err != nil {
why = fmt.Sprintf("failed signature check: %v", err)
} else {
continue
}
b.logf("Tailnet lock is dropping delta-upserted peer %v(%v) due to %v", n.ID(), n.StableID(), why)
mak.Set(&b.tka.filtered, n.ID(), tkaStateFromPeer(n))
muts[i] = netmap.MakeNodeMutationRemove(n.ID())
case netmap.NodeMutationRemove:
// If a peer is explicitly removed by control, clear it from
// the filtered set too so TailnetLockStatus doesn't report
// stale entries.
delete(b.tka.filtered, m.NodeIDBeingMutated())
}
}
return muts
}
// tkaFilterNetmapLocked checks the signatures on each node key, dropping
// nodes from the netmap whose signature does not verify.
//
@@ -139,12 +204,12 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
continue
}
if p.KeySignature().Len() == 0 {
b.logf("Network lock is dropping peer %v(%v) due to missing signature", p.ID(), p.StableID())
b.logf("Tailnet lock is dropping peer %v(%v) due to missing signature", p.ID(), p.StableID())
mak.Set(&toDelete, i, true)
} else {
details, err := b.tka.authority.NodeKeyAuthorizedWithDetails(p.Key(), p.KeySignature().AsSlice())
if err != nil {
b.logf("Network lock is dropping peer %v(%v) due to failed signature check: %v", p.ID(), p.StableID(), err)
b.logf("Tailnet lock is dropping peer %v(%v) due to failed signature check: %v", p.ID(), p.StableID(), err)
mak.Set(&toDelete, i, true)
continue
}
@@ -160,16 +225,15 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
// nm.Peers is ordered, so deletion must be order-preserving.
if len(toDelete) > 0 || len(obsoleteByRotation) > 0 {
peers := make([]tailcfg.NodeView, 0, len(nm.Peers))
filtered := make([]ipnstate.TKAPeer, 0, len(toDelete)+len(obsoleteByRotation))
filtered := make(map[tailcfg.NodeID]ipnstate.TKAPeer, len(toDelete)+len(obsoleteByRotation))
for i, p := range nm.Peers {
if !toDelete[i] && !obsoleteByRotation.Contains(p.Key()) {
peers = append(peers, p)
} else {
if obsoleteByRotation.Contains(p.Key()) {
b.logf("Network lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID())
b.logf("Tailnet lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID())
}
// Record information about the node we filtered out.
filtered = append(filtered, tkaStateFromPeer(p))
filtered[p.ID()] = tkaStateFromPeer(p)
}
}
nm.Peers = peers
@@ -304,7 +368,11 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
wantEnabled := nm.TKAEnabled
if isEnabled || wantEnabled {
b.logf("tkaSyncIfNeeded: isEnabled=%t, wantEnabled=%t, head=%v", isEnabled, wantEnabled, nm.TKAHead)
nodeHead := "<not-enabled>"
if b.tka != nil {
nodeHead = b.tka.authority.Head().String()
}
b.logf("tkaSyncIfNeeded: isEnabled=%t, wantEnabled=%t, nodeHead=%v, netmapHead=%v", isEnabled, wantEnabled, nodeHead, nm.TKAHead)
}
ourNodeKey, ok := prefs.Persist().PublicNodeKeyOK()
@@ -360,7 +428,7 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
//
// We run this on every sync so that clients compact consistently. In many
// cases this will be a no-op.
if err := b.tka.authority.Compact(b.tka.storage, tkaCompactionDefaults); err != nil {
if err := b.tka.authority.Compact(b.tka.storage, tka.CompactionDefaults); err != nil {
return fmt.Errorf("tka compact: %w", err)
}
}
@@ -407,7 +475,7 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
// has updates for us, or we have updates for the control plane.
//
// TODO(tom): Do we want to keep processing even if the Inform fails? Need
// to think through if theres holdback concerns here or not.
// to think through if there's holdback concerns here or not.
if len(offerResp.MissingAUMs) > 0 {
aums := make([]tka.AUM, len(offerResp.MissingAUMs))
for i, a := range offerResp.MissingAUMs {
@@ -491,8 +559,8 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
root := b.TailscaleVarRoot()
var storage tka.CompactableChonk
if root == "" {
b.health.SetUnhealthy(noNetworkLockStateDirWarnable, nil)
b.logf("network-lock using in-memory storage; no state directory")
b.health.SetUnhealthy(noTailnetLockStateDirWarnable, nil)
b.logf("tailnet-lock using in-memory storage; no state directory")
storage = tka.ChonkMem()
} else {
chonkDir := b.chonkPathLocked()
@@ -515,9 +583,9 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
return nil
}
// NetworkLockStatus returns a structure describing the state of the
// TailnetLockStatus returns a structure describing the state of the
// tailnet key authority, if any.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
func (b *LocalBackend) TailnetLockStatus() *ipnstate.TailnetLockStatus {
b.mu.Lock()
defer b.mu.Unlock()
@@ -532,13 +600,13 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
if nlPriv.IsZero() {
return &ipnstate.NetworkLockStatus{
return &ipnstate.TailnetLockStatus{
Enabled: false,
NodeKey: nodeKey,
}
}
if b.tka == nil {
return &ipnstate.NetworkLockStatus{
return &ipnstate.TailnetLockStatus{
Enabled: false,
NodeKey: nodeKey,
PublicKey: nlPriv.Public(),
@@ -570,9 +638,9 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
}
filtered := make([]*ipnstate.TKAPeer, len(b.tka.filtered))
for i := range len(filtered) {
filtered[i] = b.tka.filtered[i].Clone()
var filtered []*ipnstate.TKAPeer
for _, fp := range b.tka.filtered {
filtered = append(filtered, new(fp))
}
var visible []*ipnstate.TKAPeer
@@ -586,7 +654,7 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
stateID1, _ := b.tka.authority.StateIDs()
return &ipnstate.NetworkLockStatus{
return &ipnstate.TailnetLockStatus{
Enabled: true,
Head: &head,
PublicKey: nlPriv.Public(),
@@ -600,6 +668,11 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
}
// Deprecated: use [LocalBackend.TailnetLockStatus] instead.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.TailnetLockStatus {
return b.TailnetLockStatus()
}
func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
fp := ipnstate.TKAPeer{
Name: p.Name(),
@@ -620,7 +693,7 @@ func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
return fp
}
// NetworkLockInit enables network-lock for the tailnet, with the tailnets'
// TailnetLockInit enables tailnet-lock for the tailnet, with the tailnets'
// key authority initialized to trust the provided keys.
//
// Initialization involves two RPCs with control, termed 'begin' and 'finish'.
@@ -628,8 +701,8 @@ func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
// encodes the initial state of the authority, and the list of all nodes
// needing signatures is returned as a response.
// The Finish RPC submits signatures for all these nodes, at which point
// Control has everything it needs to atomically enable network lock.
func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
// Control has everything it needs to atomically enable tailnet lock.
func (b *LocalBackend) TailnetLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
var ourNodeKey key.NodePublic
var nlPriv key.NLPrivate
@@ -654,21 +727,16 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
// the filesystem until we've finished the initialization sequence,
// just in case something goes wrong.
_, genesisAUM, err := tka.Create(tka.ChonkMem(), tka.State{
Keys: keys,
// TODO(tom): s/tka.State.DisablementSecrets/tka.State.DisablementValues
// This will center on consistent nomenclature:
// - DisablementSecret: value needed to disable.
// - DisablementValue: the KDF of the disablement secret, a public value.
DisablementSecrets: disablementValues,
StateID1: binary.LittleEndian.Uint64(entropy[:8]),
StateID2: binary.LittleEndian.Uint64(entropy[8:]),
Keys: keys,
DisablementValues: disablementValues,
StateID1: binary.LittleEndian.Uint64(entropy[:8]),
StateID2: binary.LittleEndian.Uint64(entropy[8:]),
}, nlPriv)
if err != nil {
return fmt.Errorf("tka.Create: %v", err)
}
b.logf("Generated genesis AUM to initialize network lock, trusting the following keys:")
b.logf("Generated genesis AUM to initialize tailnet lock, trusting the following keys:")
for i, k := range genesisAUM.State.Keys {
b.logf(" - key[%d] = tlpub:%x with %d votes", i, k.Public, k.Votes)
}
@@ -683,7 +751,7 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
// node-key signatures, we need to sign keys for all the existing nodes.
// If we don't get these signatures ahead of time, everyone will lose
// connectivity because control won't have any signatures to send which
// satisfy network-lock checks.
// satisfy tailnet-lock checks.
sigs := make(map[tailcfg.NodeID]tkatype.MarshaledSignature, len(initResp.NeedSignatures))
for _, nodeInfo := range initResp.NeedSignatures {
nks, err := signNodeKey(nodeInfo, nlPriv)
@@ -699,40 +767,54 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
return err
}
// NetworkLockAllowed reports whether the node is allowed to use Tailnet Lock.
func (b *LocalBackend) NetworkLockAllowed() bool {
// Deprecated: use [LocalBackend.TailnetLockInit] instead.
func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
return b.TailnetLockInit(keys, disablementValues, supportDisablement)
}
// TailnetLockAllowed reports whether the node is allowed to use Tailnet Lock.
func (b *LocalBackend) TailnetLockAllowed() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.capTailnetLock
}
// Only use is in tests.
func (b *LocalBackend) NetworkLockVerifySignatureForTest(nks tkatype.MarshaledSignature, nodeKey key.NodePublic) error {
// Deprecated: use [LocalBackend.TailnetLockAllowed] instead.
func (b *LocalBackend) NetworkLockAllowed() bool {
return b.TailnetLockAllowed()
}
// TailnetLockVerifySignature verifies that nks is a valid tailnet lock
// signature for the given node key.
func (f forTest) TailnetLockVerifySignature(nks tkatype.MarshaledSignature, nodeKey key.NodePublic) error {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
return b.tka.authority.NodeKeyAuthorized(nodeKey, nks)
}
// Only use is in tests.
func (b *LocalBackend) NetworkLockKeyTrustedForTest(keyID tkatype.KeyID) bool {
// TailnetLockKeyTrusted reports whether keyID is trusted by the tailnet lock
// authority. It panics if tailnet lock is not initialized.
func (f forTest) TailnetLockKeyTrusted(keyID tkatype.KeyID) bool {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
panic("network lock not initialized")
panic("tailnet lock not initialized")
}
return b.tka.authority.KeyTrusted(keyID)
}
// NetworkLockForceLocalDisable shuts down TKA locally, and denylists the current
// TailnetLockForceLocalDisable shuts down TKA locally, and denylists the current
// TKA from being initialized locally in future.
func (b *LocalBackend) NetworkLockForceLocalDisable() error {
func (b *LocalBackend) TailnetLockForceLocalDisable() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
id1, id2 := b.tka.authority.StateIDs()
@@ -752,9 +834,14 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
return nil
}
// NetworkLockSign signs the given node-key and submits it to the control plane.
// Deprecated: use [LocalBackend.TailnetLockForceLocalDisable] instead.
func (b *LocalBackend) NetworkLockForceLocalDisable() error {
return b.TailnetLockForceLocalDisable()
}
// TailnetLockSign signs the given node-key and submits it to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
func (b *LocalBackend) TailnetLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
ourNodeKey, sig, err := func(nodeKey key.NodePublic, rotationPublic []byte) (key.NodePublic, tka.NodeKeySignature, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -768,7 +855,7 @@ func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []
}
if b.tka == nil {
return key.NodePublic{}, tka.NodeKeySignature{}, errNetworkLockNotActive
return key.NodePublic{}, tka.NodeKeySignature{}, errTailnetLockNotActive
}
if !b.tka.authority.KeyTrusted(nlPriv.KeyID()) {
return key.NodePublic{}, tka.NodeKeySignature{}, errors.New(tsconst.TailnetLockNotTrustedMsg)
@@ -795,18 +882,23 @@ func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []
return err
}
b.logf("Generated network-lock signature for %v, submitting to control plane", nodeKey)
b.logf("Generated tailnet-lock signature for %v, submitting to control plane", nodeKey)
if _, err := b.tkaSubmitSignature(ourNodeKey, sig.Serialize()); err != nil {
return err
}
return nil
}
// NetworkLockModify adds and/or removes keys in the tailnet's key authority.
func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err error) {
// Deprecated: use [LocalBackend.TailnetLockSign] instead.
func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
return b.TailnetLockSign(nodeKey, rotationPublic)
}
// TailnetLockModify adds and/or removes keys in the tailnet's key authority.
func (b *LocalBackend) TailnetLockModify(addKeys, removeKeys []tka.Key) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("modify network-lock keys: %w", err)
err = fmt.Errorf("modify tailnet-lock keys: %w", err)
}
}()
@@ -829,7 +921,7 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err
return errMissingNetmap
}
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
if !b.tka.authority.KeyTrusted(nlPriv.KeyID()) {
return errors.New("this node does not have a trusted tailnet lock key")
@@ -882,8 +974,13 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err
return nil
}
// NetworkLockDisable disables network-lock using the provided disablement secret.
func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
// Deprecated: use [LocalBackend.TailnetLockModify] instead.
func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err error) {
return b.TailnetLockModify(addKeys, removeKeys)
}
// TailnetLockDisable disables tailnet-lock using the provided disablement secret.
func (b *LocalBackend) TailnetLockDisable(secret []byte) error {
var (
ourNodeKey key.NodePublic
head tka.AUMHash
@@ -895,7 +992,7 @@ func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
ourNodeKey = p.Persist().PublicNodeKey()
}
if b.tka == nil {
err = errNetworkLockNotActive
err = errTailnetLockNotActive
} else {
head = b.tka.authority.Head()
if !b.tka.authority.ValidDisablement(secret) {
@@ -914,16 +1011,21 @@ func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
return err
}
// NetworkLockLog returns the changelog of TKA state up to maxEntries in size.
func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
// Deprecated: use [LocalBackend.TailnetLockDisable] instead.
func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
return b.TailnetLockDisable(secret)
}
// TailnetLockLog returns the changelog of TKA state up to maxEntries in size.
func (b *LocalBackend) TailnetLockLog(maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
var out []ipnstate.NetworkLockUpdate
var out []ipnstate.TailnetLockUpdate
cursor := b.tka.authority.Head()
for range maxEntries {
aum, err := b.tka.storage.AUM(cursor)
@@ -934,7 +1036,7 @@ func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpd
return out, fmt.Errorf("reading AUM (%v): %w", cursor, err)
}
update := ipnstate.NetworkLockUpdate{
update := ipnstate.TailnetLockUpdate{
Hash: cursor,
Change: aum.MessageKind.String(),
Raw: aum.Serialize(),
@@ -951,9 +1053,14 @@ func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpd
return out, nil
}
// NetworkLockAffectedSigs returns the signatures which would be invalidated
// Deprecated: use [LocalBackend.TailnetLockLog] instead.
func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
return b.TailnetLockLog(maxEntries)
}
// TailnetLockAffectedSigs returns the signatures which would be invalidated
// by removing trust in the specified KeyID.
func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
func (b *LocalBackend) TailnetLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
var (
ourNodeKey key.NodePublic
err error
@@ -963,7 +1070,7 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
ourNodeKey = p.Persist().PublicNodeKey()
}
if b.tka == nil {
err = errNetworkLockNotActive
err = errTailnetLockNotActive
}
b.mu.Unlock()
if err != nil {
@@ -978,7 +1085,7 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
// Confirm for ourselves tha the signatures would actually be invalidated
@@ -1009,16 +1116,21 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
return resp.Signatures, nil
}
// NetworkLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// Deprecated: use [LocalBackend.TailnetLockAffectedSigs] instead.
func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
return b.TailnetLockAffectedSigs(keyID)
}
// TailnetLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// specified keys. This AUM is signed by the current node and returned.
//
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
// the parent AUM is determined automatically.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
func (b *LocalBackend) TailnetLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
@@ -1042,16 +1154,21 @@ func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID
return aum, nil
}
// NetworkLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// Deprecated: use [LocalBackend.TailnetLockGenerateRecoveryAUM] instead.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
return b.TailnetLockGenerateRecoveryAUM(removeKeys, forkFrom)
}
// TailnetLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// the updated structure.
//
// The recovery AUM provided should be the output from a previous call to
// NetworkLockGenerateRecoveryAUM or NetworkLockCosignRecoveryAUM.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
// [LocalBackend.TailnetLockGenerateRecoveryAUM] or [LocalBackend.TailnetLockCosignRecoveryAUM].
func (b *LocalBackend) TailnetLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
@@ -1076,11 +1193,16 @@ func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, err
return aum, nil
}
func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
// Deprecated: use [LocalBackend.TailnetLockCosignRecoveryAUM] instead.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
return b.TailnetLockCosignRecoveryAUM(aum)
}
func (b *LocalBackend) TailnetLockSubmitRecoveryAUM(aum *tka.AUM) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
var ourNodeKey key.NodePublic
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
@@ -1096,19 +1218,24 @@ func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
return err
}
// Deprecated: use [LocalBackend.TailnetLockSubmitRecoveryAUM] instead.
func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
return b.TailnetLockSubmitRecoveryAUM(aum)
}
var tkaSuffixEncoder = base64.RawStdEncoding
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// TailnetLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
//
// The provided trusted tailnet-lock key is used to sign
// a SigCredential structure, which is encoded along with the
// private key and appended to the pre-auth key.
func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
func (b *LocalBackend) TailnetLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return "", errNetworkLockNotActive
return "", errTailnetLockNotActive
}
pub, priv, err := ed25519.GenerateKey(nil) // nil == crypto/rand
@@ -1126,22 +1253,32 @@ func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.N
return "", fmt.Errorf("signing failed: %w", err)
}
b.logf("Generated network-lock credential signature using %s", tkaKey.Public().CLIString())
b.logf("Generated tailnet-lock credential signature using %s", tkaKey.Public().CLIString())
return fmt.Sprintf("%s--TL%s-%s", preauthKey, tkaSuffixEncoder.EncodeToString(sig.Serialize()), tkaSuffixEncoder.EncodeToString(priv)), nil
}
// NetworkLockVerifySigningDeeplink asks the authority to verify the given deeplink
// Deprecated: use [LocalBackend.TailnetLockWrapPreauthKey] instead.
func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
return b.TailnetLockWrapPreauthKey(preauthKey, tkaKey)
}
// TailnetLockVerifySigningDeeplink asks the authority to verify the given deeplink
// URL. See the comment for ValidateDeeplink for details.
func (b *LocalBackend) NetworkLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult {
func (b *LocalBackend) TailnetLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return tka.DeeplinkValidationResult{IsValid: false, Error: errNetworkLockNotActive.Error()}
return tka.DeeplinkValidationResult{IsValid: false, Error: errTailnetLockNotActive.Error()}
}
return b.tka.authority.ValidateDeeplink(url)
}
// Deprecated: use [LocalBackend.TailnetLockVerifySigningDeeplink] instead.
func (b *LocalBackend) NetworkLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult {
return b.TailnetLockVerifySigningDeeplink(url)
}
func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
p, err := nodeInfo.NodePublic.MarshalBinary()
if err != nil {
@@ -1173,6 +1310,8 @@ func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*ta
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// The response carries key signatures for every node in the tailnet.
ctx = httpbody.WithMaxSize(ctx, tkaMaxResponseBodySize)
req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/init/begin", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
@@ -1187,7 +1326,7 @@ func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*ta
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKAInitBeginResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1224,7 +1363,7 @@ func (b *LocalBackend) tkaInitFinish(ourNodeKey key.NodePublic, nks map[tailcfg.
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKAInitFinishResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1272,7 +1411,7 @@ func (b *LocalBackend) tkaFetchBootstrap(ourNodeKey key.NodePublic, head tka.AUM
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKABootstrapResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1302,6 +1441,8 @@ func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncO
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// The response carries AUMs the control plane believes we're missing.
ctx = httpbody.WithMaxSize(ctx, tkaMaxResponseBodySize)
req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/sync/offer", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
@@ -1316,7 +1457,7 @@ func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncO
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKASyncOfferResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1365,7 +1506,7 @@ func (b *LocalBackend) tkaDoSyncSend(ourNodeKey key.NodePublic, head tka.AUMHash
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKASyncSendResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1407,7 +1548,7 @@ func (b *LocalBackend) tkaDoDisablement(ourNodeKey key.NodePublic, head tka.AUMH
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKADisableResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1443,7 +1584,7 @@ func (b *LocalBackend) tkaSubmitSignature(ourNodeKey key.NodePublic, sig tkatype
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKASubmitSignatureResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1464,6 +1605,8 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// The response carries every signature made with the key.
ctx = httpbody.WithMaxSize(ctx, tkaMaxResponseBodySize)
req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/affected-sigs", &encodedReq)
if err != nil {
@@ -1479,7 +1622,7 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
return nil, fmt.Errorf("request returned (%d): %s", resp.StatusCode, string(body))
}
a := new(tailcfg.TKASignaturesUsingKeyResponse)
err = json.NewDecoder(&io.LimitedReader{R: resp.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(resp.Body).Decode(a)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1487,3 +1630,24 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
return a, nil
}
// LocalBackendWithTKAForTest creates a LocalBackend with an initialized TKA
// state for testing tailnet lock from the feature/tailnetlock package. Will be
// removed when tailnet lock is fully moved to its own package. Do not use this
// from any other package.
func LocalBackendWithTKAForTest(chonk tka.CompactableChonk, tka *tka.Authority) *LocalBackend {
testenv.AssertInTest()
var state *tkaState
if tka != nil {
state = &tkaState{
authority: tka,
storage: chonk,
}
}
return &LocalBackend{
store: &mem.Store{},
logf: logger.Discard,
tka: state,
}
}
+12 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_tailnetlock
@@ -26,6 +26,15 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {}
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
return &ipnstate.NetworkLockStatus{Enabled: false}
func (b *LocalBackend) tkaFilterDeltaMutsLocked(muts []netmap.NodeMutation) []netmap.NodeMutation {
return muts
}
func (b *LocalBackend) TailnetLockStatus() *ipnstate.TailnetLockStatus {
return &ipnstate.TailnetLockStatus{Enabled: false}
}
// Deprecated: use [LocalBackend.TailnetLockStatus] instead.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.TailnetLockStatus {
return b.TailnetLockStatus()
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"errors"
"fmt"
"log"
"net/netip"
"strings"
"sync"
"go4.org/netipx"
"tailscale.com/envknob"
"tailscale.com/net/tsaddr"
"tailscale.com/util/clientmetric"
)
// v4BroadcastAddr is 255.255.255.255, the limited broadcast address.
var v4BroadcastAddr = netip.AddrFrom4([4]byte{255, 255, 255, 255})
// viaAdditionsEnv is the name of an environment variable that can be set to
// override the 4via6 host-scoped address restrictions. Only use this if you are
// certain that there is not a better solution (e.g. perhaps tailscale serve,
// possibly using a Service). Use of this feature will have security
// implications: ensure that your ACLs/Grants are written appropriately.
// The value of the environment variable is a comma separated list of IPv4
// ranges (two IPv4 addresses separated by a hyphen), IPv4 prefixes (CIDR
// notation), or IPv4 addresses. Spaces are permitted around the commas.
const viaAdditionsEnv = "TS_4VIA6_ALLOW_LOCAL"
var viaTargetAdditions = sync.OnceValue(func() *netipx.IPSet {
v, err := generateViaTargetAdditions(envknob.String(viaAdditionsEnv))
if err != nil {
log.Print(err)
}
if v != nil {
has4via6AdditionsMetric := clientmetric.NewGauge("4via6_allow_local")
has4via6AdditionsMetric.Set(1)
}
return v
})
func generateViaTargetAdditions(rawAdditions string) (*netipx.IPSet, error) {
rawAdditions = strings.TrimSpace(rawAdditions)
if rawAdditions == "" {
return nil, nil
}
var ips netipx.IPSetBuilder
rawAddrs := strings.Split(rawAdditions, ",")
errs := make([]error, 0, len(rawAddrs))
for _, rawAddr := range rawAddrs {
rawAddr = strings.TrimSpace(rawAddr)
if rawAddr == "" {
continue
}
// Try parsing as a range
if ipRange, err := netipx.ParseIPRange(rawAddr); err == nil {
if !ipRange.From().Is4() || !ipRange.To().Is4() {
errs = append(errs, fmt.Errorf("rejecting %v range %v: not IPv4", viaAdditionsEnv, ipRange))
continue
}
ips.AddRange(ipRange)
continue
}
// Try parsing as a prefix
if prefix, err := netip.ParsePrefix(rawAddr); err == nil {
if !prefix.Addr().Is4() {
errs = append(errs, fmt.Errorf("rejecting %v prefix %v: not IPv4", viaAdditionsEnv, prefix))
continue
}
ips.AddPrefix(prefix)
continue
}
// Try parsing as an individual IP
if addr, err := netip.ParseAddr(rawAddr); err == nil {
if !addr.Is4() {
errs = append(errs, fmt.Errorf("rejecting %v address %v: not IPv4", viaAdditionsEnv, addr))
continue
}
ips.Add(addr)
continue
}
errs = append(errs, fmt.Errorf("rejecting %v value %q: not a range, prefix, or address", viaAdditionsEnv, rawAddr))
}
ipSet, err := ips.IPSet()
if err != nil {
errs = append(errs, err)
}
return ipSet, errors.Join(errs...)
}
// viaTargetAllowed reports whether ip may be forwarded to after unmapping a
// 4via6 destination. The packet filter only sees the outer via address, so
// this is the sole check on the embedded IPv4 target.
func viaTargetAllowed(ip netip.Addr) bool {
if !ip.Is4() {
return false // UnmapVia only returns IPv4
}
if additions := viaTargetAdditions(); additions != nil {
// Permit unusual configurations to use host-scoped IP addresses as
// 4via6 destinations when provided in an envknob.
if additions.Contains(ip) {
return true
}
}
if ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() || ip == v4BroadcastAddr {
return false
}
if tsaddr.IsTailscaleIP(ip) {
// A CGNAT-range target may route to tailscale0 or to a site LAN
// depending on the tailnet and OS (see shouldUseOneCGNATRoute);
// it's hard to detect when forwarding would be OK, so deny always
return false
}
if ip.IsLinkLocalUnicast() {
return false
}
return true
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"log"
"runtime"
"time"
"tailscale.com/tstime"
)
// deadlockProbeDelay is how long a watched call must be in flight before we
// start probing locks to check for a deadlock. Calls that complete sooner do
// not trigger any probing.
const deadlockProbeDelay = 5 * time.Second
// deadlockTimeout is how long the lock-probing goroutine is allowed to run
// before we declare a deadlock and panic with goroutine stacks. That is, it's
// the maximum total time we allow any of the probed locks to be held.
const deadlockTimeout = 30 * time.Second
// CheckDeadlocks schedules a delayed deadlock probe and returns a function to
// call when the operation being watched is done. Callers typically use it as
// "defer b.CheckDeadlocks()()" to bracket a region of code that should not
// take more than [deadlockProbeDelay].
//
// This is a backstop for detecting and debugging deadlocks in the process, replacing
// the earlier watchdogEngine removed in 2b338dd6a8dbd.
func (b *LocalBackend) CheckDeadlocks() (done func()) {
// Bump the in-flight count. If a watched region is already open, the
// probe timer is already armed, so the bump is all we need to do: the
// matching doneDeadlockCheck will decrement when this caller returns and
// only the last one out will stop the timer.
if b.deadlockChecksInFlight.Add(1) != 1 {
return b.doneDeadlockCheck
}
// Fast path to avoid the deadlockTimerMu+Timer.Reset cost when
// CheckDeadlocks is called many times per second by non-overlapping
// callers: re-arm the probe timer at most once per wall-clock second.
// We use a unix-seconds timestamp (+1 so 0 can mean "never") and a CAS
// so that only one caller per second proceeds to touch the timer; the
// rest return early.
nowUnix := tstime.DefaultClock{Clock: b.Clock()}.Now().Unix() + 1
lastUnix := b.lastDeadlockCheckUnix.Load()
if lastUnix == nowUnix || !b.lastDeadlockCheckUnix.CompareAndSwap(lastUnix, nowUnix) {
return b.doneDeadlockCheck
}
// Slow path: (re)arm the probe timer. Lazily create it on first use.
b.deadlockTimerMu.Lock()
defer b.deadlockTimerMu.Unlock()
t := b.deadlockProbeTimer
if t == nil {
t = time.AfterFunc(deadlockProbeDelay, b.runDeadlockProbe)
b.deadlockProbeTimer = t
} else {
t.Reset(deadlockProbeDelay)
}
return b.doneDeadlockCheck
}
func (b *LocalBackend) doneDeadlockCheck() {
switch n := b.deadlockChecksInFlight.Add(-1); {
case n > 0:
return
case n < 0:
panic("ipnlocal: doneDeadlockCheck called without matching CheckDeadlocks")
}
b.deadlockTimerMu.Lock()
defer b.deadlockTimerMu.Unlock()
if b.deadlockProbeTimer == nil {
return
}
b.deadlockProbeTimer.Stop()
}
func (b *LocalBackend) runDeadlockProbe() {
b.deadlockTimerMu.Lock()
defer b.deadlockTimerMu.Unlock()
if b.deadlockChecksInFlight.Load() == 0 {
return
}
t := b.deadlockTimer
if t == nil {
t = time.AfterFunc(deadlockTimeout, b.reportDeadlock)
b.deadlockTimer = t
} else {
t.Reset(deadlockTimeout)
}
defer t.Stop()
b.probeLocks()
}
func (b *LocalBackend) probeLocks() {
b.probeLocalBackendLock()
sys := b.sys
if sys == nil {
return
}
if bus, ok := sys.Bus.GetOK(); ok && bus != nil {
bus.ProbeLocks()
}
if dialer, ok := sys.Dialer.GetOK(); ok && dialer != nil {
dialer.ProbeLocks()
}
if dm, ok := sys.DNSManager.GetOK(); ok && dm != nil {
dm.ProbeLocks()
}
if e, ok := sys.Engine.GetOK(); ok && e != nil {
e.ProbeLocks()
}
if nm, ok := sys.NetMon.GetOK(); ok && nm != nil {
nm.ProbeLocks()
}
if mc, ok := sys.MagicSock.GetOK(); ok && mc != nil {
mc.ProbeLocks()
}
if tun, ok := sys.Tun.GetOK(); ok && tun != nil {
tun.ProbeLocks()
}
if ht, ok := sys.HealthTracker.GetOK(); ok && ht != nil {
ht.ProbeLocks()
}
}
func (b *LocalBackend) probeLocalBackendLock() {
b.mu.Lock()
defer b.mu.Unlock()
}
func (b *LocalBackend) reportDeadlock() {
logf := b.logf
if logf == nil {
logf = log.Printf
}
logf("ipnlocal watchdog goroutine stacks:\n%s", goroutineStacks())
panic("ipnlocal: watchdog timeout")
}
func goroutineStacks() []byte {
buf := make([]byte, 256<<10)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
return buf[:n]
}
buf = make([]byte, 2*len(buf))
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !ts_omit_webclient
//go:build !android && !ts_omit_webclient
package ipnlocal
@@ -173,7 +173,7 @@ func (b *LocalBackend) waitWebClientAuthURL(ctx context.Context, id string, src
// one to be completed, based on the presence or absence of the
// provided id value.
func (b *LocalBackend) doWebClientNoiseRequest(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
nm := b.NetMap()
nm := b.NetMapNoPeers()
if nm == nil || !nm.SelfNode.Valid() {
return nil, errors.New("[unexpected] no self node")
}
+2 -2
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android || ts_omit_webclient
//go:build android || ts_omit_webclient
package ipnlocal