Update
This commit is contained in:
1 parent
3530afb854
commit
6903fcff27
2146 files changed
+112256
-190703
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
-45
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
+29
-4
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+452
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+190
-54
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
+21
-8
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnlocal
|
||||
|
||||
+198
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
Generated
Vendored
+269
-105
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user