This commit is contained in:
2026-02-19 10:07:43 +00:00
parent 007438e372
commit 6e637ecf77
1763 changed files with 60820 additions and 279516 deletions

View File

@@ -1,65 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || windows
package ipnlocal
import (
"context"
"time"
"tailscale.com/clientupdate"
"tailscale.com/ipn"
"tailscale.com/version"
)
func (b *LocalBackend) stopOfflineAutoUpdate() {
if b.offlineAutoUpdateCancel != nil {
b.logf("offline auto-update: stopping update checks")
b.offlineAutoUpdateCancel()
b.offlineAutoUpdateCancel = nil
}
}
func (b *LocalBackend) maybeStartOfflineAutoUpdate(prefs ipn.PrefsView) {
if !prefs.AutoUpdate().Apply.EqualBool(true) {
return
}
// AutoUpdate.Apply field in prefs can only be true for platforms that
// support auto-updates. But check it here again, just in case.
if !clientupdate.CanAutoUpdate() {
return
}
// On macsys, auto-updates are managed by Sparkle.
if version.IsMacSysExt() {
return
}
if b.offlineAutoUpdateCancel != nil {
// Already running.
return
}
ctx, cancel := context.WithCancel(context.Background())
b.offlineAutoUpdateCancel = cancel
b.logf("offline auto-update: starting update checks")
go b.offlineAutoUpdate(ctx)
}
const offlineAutoUpdateCheckPeriod = time.Hour
func (b *LocalBackend) offlineAutoUpdate(ctx context.Context) {
t := time.NewTicker(offlineAutoUpdateCheckPeriod)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if err := b.startAutoUpdate("offline auto-update"); err != nil {
b.logf("offline auto-update: failed: %v", err)
}
}
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !(linux || windows)
package ipnlocal
import (
"tailscale.com/ipn"
)
func (b *LocalBackend) stopOfflineAutoUpdate() {
// Not supported on this platform.
}
func (b *LocalBackend) maybeStartOfflineAutoUpdate(prefs ipn.PrefsView) {
// Not supported on this platform.
}

View File

@@ -156,5 +156,6 @@ func isNotableNotify(n *ipn.Notify) bool {
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
len(n.OutgoingFiles) > 0 ||
n.FilesWaiting != nil
n.FilesWaiting != nil ||
n.SuggestedExitNode != nil
}

View File

@@ -4,76 +4,71 @@
package ipnlocal
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"time"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/control/controlclient"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/net/sockstats"
"tailscale.com/posture"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/util/clientmetric"
"tailscale.com/util/goroutines"
"tailscale.com/util/httpm"
"tailscale.com/util/set"
"tailscale.com/util/syspolicy"
"tailscale.com/version"
"tailscale.com/version/distro"
)
// c2nHandlers maps an HTTP method and URI path (without query parameters) to
// its handler. The exact method+path match is preferred, but if no entry
// exists for that, a map entry with an empty method is used as a fallback.
var c2nHandlers = map[methodAndPath]c2nHandler{
// Debug.
req("/echo"): handleC2NEcho,
req("/debug/goroutines"): handleC2NDebugGoroutines,
req("/debug/prefs"): handleC2NDebugPrefs,
req("/debug/metrics"): handleC2NDebugMetrics,
req("/debug/component-logging"): handleC2NDebugComponentLogging,
req("/debug/logheap"): handleC2NDebugLogHeap,
var c2nHandlers map[methodAndPath]c2nHandler
// PPROF - We only expose a subset of typical pprof endpoints for security.
req("/debug/pprof/heap"): handleC2NPprof,
req("/debug/pprof/allocs"): handleC2NPprof,
func init() {
c2nHandlers = map[methodAndPath]c2nHandler{}
if buildfeatures.HasC2N {
// Echo is the basic "ping" handler as used by the control plane to probe
// whether a node is reachable. In particular, it's important for
// high-availability subnet routers for the control plane to probe which of
// 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)
}
if buildfeatures.HasDebug {
RegisterC2N("POST /sockstats", handleC2NSockStats)
req("POST /logtail/flush"): handleC2NLogtailFlush,
req("POST /sockstats"): handleC2NSockStats,
// pprof:
// we only expose a subset of typical pprof endpoints for security.
RegisterC2N("/debug/pprof/heap", handleC2NPprof)
RegisterC2N("/debug/pprof/allocs", handleC2NPprof)
// Check TLS certificate status.
req("GET /tls-cert-status"): handleC2NTLSCertStatus,
// SSH
req("/ssh/usernames"): handleC2NSSHUsernames,
// Auto-updates.
req("GET /update"): handleC2NUpdateGet,
req("POST /update"): handleC2NUpdatePost,
// Device posture.
req("GET /posture/identity"): handleC2NPostureIdentityGet,
// App Connectors.
req("GET /appconnector/routes"): handleC2NAppConnectorDomainRoutesGet,
// Linux netfilter.
req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
// VIP services.
req("GET /vip-services"): handleC2NVIPServicesGet,
RegisterC2N("/debug/goroutines", handleC2NDebugGoroutines)
RegisterC2N("/debug/prefs", handleC2NDebugPrefs)
RegisterC2N("/debug/metrics", handleC2NDebugMetrics)
RegisterC2N("/debug/component-logging", handleC2NDebugComponentLogging)
RegisterC2N("/debug/logheap", handleC2NDebugLogHeap)
RegisterC2N("/debug/netmap", handleC2NDebugNetMap)
RegisterC2N("/debug/health", handleC2NDebugHealth)
}
if runtime.GOOS == "linux" && buildfeatures.HasOSRouter {
RegisterC2N("POST /netfilter-kind", handleC2NSetNetfilterKind)
}
}
// RegisterC2N registers a new c2n handler for the given pattern.
@@ -81,6 +76,9 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
// A pattern is like "GET /foo" (specific to an HTTP method) or "/foo" (all
// methods). It panics if the pattern is already registered.
func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasC2N {
return
}
k := req(pattern)
if _, ok := c2nHandlers[k]; ok {
panic(fmt.Sprintf("c2n: duplicate handler for %q", pattern))
@@ -149,21 +147,108 @@ func handleC2NLogtailFlush(b *LocalBackend, w http.ResponseWriter, r *http.Reque
}
}
func handleC2NDebugHealth(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
var st *health.State
if buildfeatures.HasDebug && b.health != nil {
st = b.health.CurrentState()
}
writeJSON(w, st)
}
func handleC2NDebugNetMap(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasDebug {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
ctx := r.Context()
if r.Method != httpm.POST && r.Method != httpm.GET {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
b.logf("c2n: %s /debug/netmap received", r.Method)
// redactAndMarshal redacts private keys from the given netmap, clears fields
// that should be omitted, and marshals it to JSON.
redactAndMarshal := func(nm *netmap.NetworkMap, omitFields []string) (json.RawMessage, error) {
for _, f := range omitFields {
field := reflect.ValueOf(nm).Elem().FieldByName(f)
if !field.IsValid() {
b.logf("c2n: /debug/netmap: unknown field %q in omitFields", f)
continue
}
field.SetZero()
}
return json.Marshal(nm)
}
var omitFields []string
resp := &tailcfg.C2NDebugNetmapResponse{}
if r.Method == httpm.POST {
var req tailcfg.C2NDebugNetmapRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("failed to decode request body: %v", err), http.StatusBadRequest)
return
}
omitFields = req.OmitFields
if req.Candidate != nil {
cand, err := controlclient.NetmapFromMapResponseForDebug(ctx, b.unsanitizedPersist(), req.Candidate)
if err != nil {
http.Error(w, fmt.Sprintf("failed to convert candidate MapResponse: %v", err), http.StatusBadRequest)
return
}
candJSON, err := redactAndMarshal(cand, omitFields)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal candidate netmap: %v", err), http.StatusInternalServerError)
return
}
resp.Candidate = candJSON
}
}
var err error
resp.Current, err = redactAndMarshal(b.currentNode().netMapWithPeers(), omitFields)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal current netmap: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, resp)
}
func handleC2NDebugGoroutines(_ *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasDebug {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Write(goroutines.ScrubbedGoroutineDump(true))
}
func handleC2NDebugPrefs(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasDebug {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
writeJSON(w, b.Prefs())
}
func handleC2NDebugMetrics(_ *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasDebug {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
w.Header().Set("Content-Type", "text/plain")
clientmetric.WritePrometheusExpositionFormat(w)
}
func handleC2NDebugComponentLogging(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasDebug {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
component := r.FormValue("component")
secs, _ := strconv.Atoi(r.FormValue("secs"))
if secs == 0 {
@@ -206,6 +291,10 @@ func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
}
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 {
@@ -232,26 +321,6 @@ func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request)
fmt.Fprintf(w, "debug info: %v\n", sockstats.DebugInfo())
}
// handleC2NAppConnectorDomainRoutesGet handles returning the domains
// that the app connector is responsible for, as well as the resolved
// IP addresses for each domain. If the node is not configured as
// an app connector, an empty map is returned.
func handleC2NAppConnectorDomainRoutesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /appconnector/routes received")
var res tailcfg.C2NAppConnectorDomainRoutesResponse
if b.appConnector == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
return
}
res.Domains = b.appConnector.DomainRoutes()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: POST /netfilter-kind received")
@@ -277,285 +346,3 @@ func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent)
}
func handleC2NVIPServicesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /vip-services received")
var res tailcfg.C2NVIPServicesResponse
res.VIPServices = b.VIPServices()
res.ServicesHash = b.vipServiceHash(res.VIPServices)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")
res := b.newC2NUpdateResponse()
res.Started = b.c2nUpdateStarted()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func handleC2NUpdatePost(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: POST /update received")
res := b.newC2NUpdateResponse()
defer func() {
if res.Err != "" {
b.logf("c2n: POST /update failed: %s", res.Err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}()
if !res.Enabled {
res.Err = "not enabled"
return
}
if !res.Supported {
res.Err = "not supported"
return
}
// Do not update if we have active inbound SSH connections. Control can set
// force=true query parameter to override this.
if r.FormValue("force") != "true" && b.sshServer != nil && b.sshServer.NumActiveConns() > 0 {
res.Err = "not updating due to active SSH connections"
return
}
if err := b.startAutoUpdate("c2n"); err != nil {
res.Err = err.Error()
return
}
res.Started = true
}
func handleC2NPostureIdentityGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /posture/identity received")
res := tailcfg.C2NPostureIdentityResponse{}
// Only collect posture identity if enabled on the client,
// this will first check syspolicy, MDM settings like Registry
// on Windows or defaults on macOS. If they are not set, it falls
// back to the cli-flag, `--posture-checking`.
choice, err := syspolicy.GetPreferenceOption(syspolicy.PostureChecking)
if err != nil {
b.logf(
"c2n: failed to read PostureChecking from syspolicy, returning default from CLI: %s; got error: %s",
b.Prefs().PostureChecking(),
err,
)
}
if choice.ShouldEnable(b.Prefs().PostureChecking()) {
res.SerialNumbers, err = posture.GetSerialNumbers(b.logf)
if err != nil {
b.logf("c2n: GetSerialNumbers returned error: %v", err)
}
// TODO(tailscale/corp#21371, 2024-07-10): once this has landed in a stable release
// and looks good in client metrics, remove this parameter and always report MAC
// addresses.
if r.FormValue("hwaddrs") == "true" {
res.IfaceHardwareAddrs, err = posture.GetHardwareAddrs()
if err != nil {
b.logf("c2n: GetHardwareAddrs returned error: %v", err)
}
}
} else {
res.PostureDisabled = true
}
b.logf("c2n: posture identity disabled=%v reported %d serials %d hwaddrs", res.PostureDisabled, len(res.SerialNumbers), len(res.IfaceHardwareAddrs))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
// If NewUpdater does not return an error, we can update the installation.
//
// Note that we create the Updater solely to check for errors; we do not
// invoke it here. For this purpose, it is ok to pass it a zero Arguments.
prefs := b.Prefs().AutoUpdate()
return tailcfg.C2NUpdateResponse{
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply.EqualBool(true),
Supported: clientupdate.CanAutoUpdate() && !version.IsMacSysExt(),
}
}
func (b *LocalBackend) c2nUpdateStarted() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.c2nUpdateStatus.started
}
func (b *LocalBackend) setC2NUpdateStarted(v bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.c2nUpdateStatus.started = v
}
func (b *LocalBackend) trySetC2NUpdateStarted() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.c2nUpdateStatus.started {
return false
}
b.c2nUpdateStatus.started = true
return true
}
// findCmdTailscale looks for the cmd/tailscale that corresponds to the
// currently running cmd/tailscaled. It's up to the caller to verify that the
// two match, but this function does its best to find the right one. Notably, it
// doesn't use $PATH for security reasons.
func findCmdTailscale() (string, error) {
self, err := os.Executable()
if err != nil {
return "", err
}
var ts string
switch runtime.GOOS {
case "linux":
if self == "/usr/sbin/tailscaled" || self == "/usr/bin/tailscaled" {
ts = "/usr/bin/tailscale"
}
if self == "/usr/local/sbin/tailscaled" || self == "/usr/local/bin/tailscaled" {
ts = "/usr/local/bin/tailscale"
}
switch distro.Get() {
case distro.QNAP:
// The volume under /share/ where qpkg are installed is not
// predictable. But the rest of the path is.
ok, err := filepath.Match("/share/*/.qpkg/Tailscale/tailscaled", self)
if err == nil && ok {
ts = filepath.Join(filepath.Dir(self), "tailscale")
}
case distro.Unraid:
if self == "/usr/local/emhttp/plugins/tailscale/bin/tailscaled" {
ts = "/usr/local/emhttp/plugins/tailscale/bin/tailscale"
}
}
case "windows":
ts = filepath.Join(filepath.Dir(self), "tailscale.exe")
case "freebsd":
if self == "/usr/local/bin/tailscaled" {
ts = "/usr/local/bin/tailscale"
}
default:
return "", fmt.Errorf("unsupported OS %v", runtime.GOOS)
}
if ts != "" && regularFileExists(ts) {
return ts, nil
}
return "", errors.New("tailscale executable not found in expected place")
}
func tailscaleUpdateCmd(cmdTS string) *exec.Cmd {
defaultCmd := exec.Command(cmdTS, "update", "--yes")
if runtime.GOOS != "linux" {
return defaultCmd
}
if _, err := exec.LookPath("systemd-run"); err != nil {
return defaultCmd
}
// When systemd-run is available, use it to run the update command. This
// creates a new temporary unit separate from the tailscaled unit. When
// tailscaled is restarted during the update, systemd won't kill this
// temporary update unit, which could cause unexpected breakage.
//
// We want to use a few optional flags:
// * --wait, to block the update command until completion (added in systemd 232)
// * --pipe, to collect stdout/stderr (added in systemd 235)
// * --collect, to clean up failed runs from memory (added in systemd 236)
//
// We need to check the version of systemd to figure out if those flags are
// available.
//
// The output will look like:
//
// systemd 255 (255.7-1-arch)
// +PAM +AUDIT ... other feature flags ...
systemdVerOut, err := exec.Command("systemd-run", "--version").Output()
if err != nil {
return defaultCmd
}
parts := strings.Fields(string(systemdVerOut))
if len(parts) < 2 || parts[0] != "systemd" {
return defaultCmd
}
systemdVer, err := strconv.Atoi(parts[1])
if err != nil {
return defaultCmd
}
if systemdVer >= 236 {
return exec.Command("systemd-run", "--wait", "--pipe", "--collect", cmdTS, "update", "--yes")
} else if systemdVer >= 235 {
return exec.Command("systemd-run", "--wait", "--pipe", cmdTS, "update", "--yes")
} else if systemdVer >= 232 {
return exec.Command("systemd-run", "--wait", cmdTS, "update", "--yes")
} else {
return exec.Command("systemd-run", cmdTS, "update", "--yes")
}
}
func regularFileExists(path string) bool {
fi, err := os.Stat(path)
return err == nil && fi.Mode().IsRegular()
}
// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
// provided domain. This can be called by the controlplane to clean up DNS TXT
// records when they're no longer needed by LetsEncrypt.
//
// It does not kick off a cert fetch or async refresh. It only reports anything
// that's already sitting on disk, and only reports metadata about the public
// cert (stuff that'd be the in CT logs anyway).
func handleC2NTLSCertStatus(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
cs, err := b.getCertStore()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
domain := r.FormValue("domain")
if domain == "" {
http.Error(w, "no 'domain'", http.StatusBadRequest)
return
}
ret := &tailcfg.C2NTLSCertInfo{}
pair, err := getCertPEMCached(cs, domain, b.clock.Now())
ret.Valid = err == nil
if err != nil {
ret.Error = err.Error()
if errors.Is(err, errCertExpired) {
ret.Expired = true
} else if errors.Is(err, ipn.ErrStateNotExist) {
ret.Missing = true
ret.Error = "no certificate"
}
} else {
block, _ := pem.Decode(pair.CertPEM)
if block == nil {
ret.Error = "invalid PEM"
ret.Valid = false
} else {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
ret.Error = fmt.Sprintf("invalid certificate: %v", err)
ret.Valid = false
} else {
ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339)
ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339)
}
}
}
writeJSON(w, ret)
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !wasm
//go:build !js && !wasm && !ts_omit_debug
package ipnlocal

186
vendor/tailscale.com/ipn/ipnlocal/captiveportal.go generated vendored Normal file
View File

@@ -0,0 +1,186 @@
// 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)
}
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js
//go:build !js && !ts_omit_acme
package ipnlocal
@@ -24,22 +24,25 @@ import (
"log"
randv2 "math/rand/v2"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"tailscale.com/atomicfile"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/bakedroots"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
"tailscale.com/types/logger"
"tailscale.com/util/testenv"
@@ -47,15 +50,19 @@ import (
"tailscale.com/version/distro"
)
func init() {
RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatus)
}
// Process-wide cache. (A new *Handler is created per connection,
// effectively per request)
var (
// acmeMu guards all ACME operations, so concurrent requests
// for certs don't slam ACME. The first will go through and
// populate the on-disk cache and the rest should use that.
acmeMu sync.Mutex
acmeMu syncs.Mutex
renewMu sync.Mutex // lock order: acmeMu before renewMu
renewMu syncs.Mutex // lock order: acmeMu before renewMu
renewCertAt = map[string]time.Time{}
)
@@ -67,7 +74,7 @@ func (b *LocalBackend) certDir() (string, error) {
// As a workaround for Synology DSM6 not having a "var" directory, use the
// app's "etc" directory (on a small partition) to hold certs at least.
// See https://github.com/tailscale/tailscale/issues/4060#issuecomment-1186592251
if d == "" && runtime.GOOS == "linux" && distro.Get() == distro.Synology && distro.DSMVersion() == 6 {
if buildfeatures.HasSynology && d == "" && runtime.GOOS == "linux" && distro.Get() == distro.Synology && distro.DSMVersion() == 6 {
d = "/var/packages/Tailscale/etc" // base; we append "certs" below
}
if d == "" {
@@ -100,6 +107,15 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
// If a cert is expired, or expires sooner than minValidity, it will be renewed
// synchronously. Otherwise it will be renewed asynchronously.
func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) {
b.mu.Lock()
getCertForTest := b.getCertForTest
b.mu.Unlock()
if getCertForTest != nil {
testenv.AssertInTest()
return getCertForTest(domain)
}
if !validLookingCertDomain(domain) {
return nil, errors.New("invalid domain")
}
@@ -137,7 +153,11 @@ func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string
if minValidity == 0 {
logf("starting async renewal")
// Start renewal in the background, return current valid cert.
b.goTracker.Go(func() { getCertPEM(context.Background(), b, cs, logf, traceACME, domain, now, minValidity) })
b.goTracker.Go(func() {
if _, err := getCertPEM(context.Background(), b, cs, logf, traceACME, domain, now, minValidity); err != nil {
logf("async renewal failed: getCertPem: %v", err)
}
})
return pair, nil
}
// If the caller requested a specific validity duration, fall through
@@ -292,6 +312,16 @@ func (b *LocalBackend) getCertStore() (certStore, error) {
return certFileStore{dir: dir, testRoots: testX509Roots}, nil
}
// ConfigureCertsForTest sets a certificate retrieval function to be used by
// this local backend, skipping the usual ACME certificate registration. Should
// only be used in tests.
func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLSCertKeyPair, error)) {
testenv.AssertInTest()
b.mu.Lock()
b.getCertForTest = getCert
b.mu.Unlock()
}
// certFileStore implements certStore by storing the cert & key files in the named directory.
type certFileStore struct {
dir string
@@ -484,14 +514,15 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
// In case this method was triggered multiple times in parallel (when
// serving incoming requests), check whether one of the other goroutines
// already renewed the cert before us.
if p, err := getCertPEMCached(cs, domain, now); err == nil {
previous, err := getCertPEMCached(cs, domain, now)
if err == nil {
// shouldStartDomainRenewal caches its result so it's OK to call this
// frequently.
shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, p, minValidity)
shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, previous, minValidity)
if err != nil {
logf("error checking for certificate renewal: %v", err)
} else if !shouldRenew {
return p, nil
return previous, nil
}
} else if !errors.Is(err, ipn.ErrStateNotExist) && !errors.Is(err, errCertExpired) {
return nil, err
@@ -536,7 +567,20 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
return nil, err
}
order, err := ac.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: domain}})
// If we have a previous cert, include it in the order. Assuming we're
// within the ARI renewal window this should exclude us from LE rate
// limits.
// Note that this order extension will fail renewals if the ACME account key has changed
// since the last issuance, see
// https://github.com/tailscale/tailscale/issues/18251
var opts []acme.OrderOption
if previous != nil && !envknob.Bool("TS_DEBUG_ACME_FORCE_RENEWAL") {
prevCrt, err := previous.parseCertificate()
if err == nil {
opts = append(opts, acme.WithOrderReplacesCert(prevCrt))
}
}
order, err := ac.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: domain}}, opts...)
if err != nil {
return nil, err
}
@@ -825,3 +869,54 @@ func checkCertDomain(st *ipnstate.Status, domain string) error {
}
return fmt.Errorf("invalid domain %q; must be one of %q", domain, st.CertDomains)
}
// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the
// provided domain. This can be called by the controlplane to clean up DNS TXT
// records when they're no longer needed by LetsEncrypt.
//
// It does not kick off a cert fetch or async refresh. It only reports anything
// that's already sitting on disk, and only reports metadata about the public
// cert (stuff that'd be the in CT logs anyway).
func handleC2NTLSCertStatus(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
cs, err := b.getCertStore()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
domain := r.FormValue("domain")
if domain == "" {
http.Error(w, "no 'domain'", http.StatusBadRequest)
return
}
ret := &tailcfg.C2NTLSCertInfo{}
pair, err := getCertPEMCached(cs, domain, b.clock.Now())
ret.Valid = err == nil
if err != nil {
ret.Error = err.Error()
if errors.Is(err, errCertExpired) {
ret.Expired = true
} else if errors.Is(err, ipn.ErrStateNotExist) {
ret.Missing = true
ret.Error = "no certificate"
}
} else {
block, _ := pem.Decode(pair.CertPEM)
if block == nil {
ret.Error = "invalid PEM"
ret.Valid = false
} else {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
ret.Error = fmt.Sprintf("invalid certificate: %v", err)
ret.Valid = false
} else {
ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339)
ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339)
}
}
}
writeJSON(w, ret)
}

View File

@@ -1,20 +1,30 @@
// 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, errors.New("not implemented for js/wasm")
return nil, errNoCerts
}
var errCertExpired = errors.New("cert expired")
@@ -22,9 +32,14 @@ var errCertExpired = errors.New("cert expired")
type certStore interface{}
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
return nil, errors.New("not implemented for js/wasm")
return nil, errNoCerts
}
func (b *LocalBackend) getCertStore() (certStore, error) {
return nil, errors.New("not implemented for js/wasm")
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
}

View File

@@ -1,178 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Both the desktop session manager and multi-user support
// are currently available only on Windows.
// This file does not need to be built for other platforms.
//go:build windows && !ts_omit_desktop_sessions
package ipnlocal
import (
"cmp"
"errors"
"fmt"
"sync"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/desktop"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/util/syspolicy"
)
func init() {
feature.Register("desktop-sessions")
RegisterExtension("desktop-sessions", newDesktopSessionsExt)
}
// desktopSessionsExt implements [localBackendExtension].
var _ localBackendExtension = (*desktopSessionsExt)(nil)
// desktopSessionsExt extends [LocalBackend] with desktop session management.
// It keeps Tailscale running in the background if Always-On mode is enabled,
// and switches to an appropriate profile when a user signs in or out,
// locks their screen, or disconnects a remote session.
type desktopSessionsExt struct {
logf logger.Logf
sm desktop.SessionManager
*LocalBackend // or nil, until Init is called
cleanup []func() // cleanup functions to call on shutdown
// mu protects all following fields.
// When both mu and [LocalBackend.mu] need to be taken,
// [LocalBackend.mu] must be taken before mu.
mu sync.Mutex
id2sess map[desktop.SessionID]*desktop.Session
}
// newDesktopSessionsExt returns a new [desktopSessionsExt],
// or an error if [desktop.SessionManager] is not available.
func newDesktopSessionsExt(logf logger.Logf, sys *tsd.System) (localBackendExtension, error) {
sm, ok := sys.SessionManager.GetOK()
if !ok {
return nil, errors.New("session manager is not available")
}
return &desktopSessionsExt{logf: logf, sm: sm, id2sess: make(map[desktop.SessionID]*desktop.Session)}, nil
}
// Init implements [localBackendExtension].
func (e *desktopSessionsExt) Init(lb *LocalBackend) (err error) {
e.LocalBackend = lb
unregisterResolver := lb.RegisterBackgroundProfileResolver(e.getBackgroundProfile)
unregisterSessionCb, err := e.sm.RegisterStateCallback(e.updateDesktopSessionState)
if err != nil {
unregisterResolver()
return fmt.Errorf("session callback registration failed: %w", err)
}
e.cleanup = []func(){unregisterResolver, unregisterSessionCb}
return nil
}
// updateDesktopSessionState is a [desktop.SessionStateCallback]
// invoked by [desktop.SessionManager] once for each existing session
// and whenever the session state changes. It updates the session map
// and switches to the best profile if necessary.
func (e *desktopSessionsExt) updateDesktopSessionState(session *desktop.Session) {
e.mu.Lock()
if session.Status != desktop.ClosedSession {
e.id2sess[session.ID] = session
} else {
delete(e.id2sess, session.ID)
}
e.mu.Unlock()
var action string
switch session.Status {
case desktop.ForegroundSession:
// The user has either signed in or unlocked their session.
// For remote sessions, this may also mean the user has connected.
// The distinction isn't important for our purposes,
// so let's always say "signed in".
action = "signed in to"
case desktop.BackgroundSession:
action = "locked"
case desktop.ClosedSession:
action = "signed out from"
default:
panic("unreachable")
}
maybeUsername, _ := session.User.Username()
userIdentifier := cmp.Or(maybeUsername, string(session.User.UserID()), "user")
reason := fmt.Sprintf("%s %s session %v", userIdentifier, action, session.ID)
e.SwitchToBestProfile(reason)
}
// getBackgroundProfile is a [profileResolver] that works as follows:
//
// If Always-On mode is disabled, it returns no profile ("","",false).
//
// If AlwaysOn mode is enabled, it returns the current profile unless:
// - The current user has signed out.
// - Another user has a foreground (i.e. active/unlocked) session.
//
// If the current user's session runs in the background and no other user
// has a foreground session, it returns the current profile. This applies
// when a locally signed-in user locks their screen or when a remote user
// disconnects without signing out.
//
// In all other cases, it returns no profile ("","",false).
//
// It is called with [LocalBackend.mu] locked.
func (e *desktopSessionsExt) getBackgroundProfile() (_ ipn.WindowsUserID, _ ipn.ProfileID, ok bool) {
e.mu.Lock()
defer e.mu.Unlock()
if alwaysOn, _ := syspolicy.GetBoolean(syspolicy.AlwaysOn, false); !alwaysOn {
return "", "", false
}
isCurrentUserSingedIn := false
var foregroundUIDs []ipn.WindowsUserID
for _, s := range e.id2sess {
switch uid := s.User.UserID(); uid {
case e.pm.CurrentUserID():
isCurrentUserSingedIn = true
if s.Status == desktop.ForegroundSession {
// Keep the current profile if the user has a foreground session.
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
default:
if s.Status == desktop.ForegroundSession {
foregroundUIDs = append(foregroundUIDs, uid)
}
}
}
// If there's no current user (e.g., tailscaled just started), or if the current
// user has no foreground session, switch to the default profile of the first user
// with a foreground session, if any.
for _, uid := range foregroundUIDs {
if profileID := e.pm.DefaultUserProfileID(uid); profileID != "" {
return uid, profileID, true
}
}
// If no user has a foreground session but the current user is still signed in,
// keep the current profile even if the session is not in the foreground,
// such as when the screen is locked or a remote session is disconnected.
if len(foregroundUIDs) == 0 && isCurrentUserSingedIn {
return e.pm.CurrentUserID(), e.pm.CurrentProfile().ID(), true
}
return "", "", false
}
// Shutdown implements [localBackendExtension].
func (e *desktopSessionsExt) Shutdown() error {
for _, f := range e.cleanup {
f()
}
e.cleanup = nil
e.LocalBackend = nil
return nil
}

View File

@@ -1,51 +1,35 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package ipnlocal
import (
"cmp"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"os"
"slices"
"tailscale.com/drive"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
const (
// DriveLocalPort is the port on which the Taildrive listens for location
// connections on quad 100.
DriveLocalPort = 8080
)
// DriveSharingEnabled reports whether sharing to remote nodes via Taildrive is
// enabled. This is currently based on checking for the drive:share node
// attribute.
func (b *LocalBackend) DriveSharingEnabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.driveSharingEnabledLocked()
func init() {
hookSetNetMapLockedDrive.Set(setNetMapLockedDrive)
}
func (b *LocalBackend) driveSharingEnabledLocked() bool {
return b.netMap != nil && b.netMap.SelfNode.HasCap(tailcfg.NodeAttrsTaildriveShare)
}
// 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 {
b.mu.Lock()
defer b.mu.Unlock()
return b.driveAccessEnabledLocked()
}
func (b *LocalBackend) driveAccessEnabledLocked() bool {
return b.netMap != nil && b.netMap.SelfNode.HasCap(tailcfg.NodeAttrsTaildriveAccess)
func setNetMapLockedDrive(b *LocalBackend, nm *netmap.NetworkMap) {
b.updateDrivePeersLocked(nm)
b.driveNotifyCurrentSharesLocked()
}
// DriveSetServerAddr tells Taildrive to use the given address for connecting
@@ -266,7 +250,7 @@ func (b *LocalBackend) driveNotifyShares(shares views.SliceView[*drive.Share, dr
// shares has changed since the last notification.
func (b *LocalBackend) driveNotifyCurrentSharesLocked() {
var shares views.SliceView[*drive.Share, drive.ShareView]
if b.driveSharingEnabledLocked() {
if b.DriveSharingEnabled() {
// Only populate shares if sharing is enabled.
shares = b.pm.prefs.DriveShares()
}
@@ -310,59 +294,206 @@ func (b *LocalBackend) updateDrivePeersLocked(nm *netmap.NetworkMap) {
}
var driveRemotes []*drive.Remote
if b.driveAccessEnabledLocked() {
if b.DriveAccessEnabled() {
// Only populate peers if access is enabled, otherwise leave blank.
driveRemotes = b.driveRemotesFromPeers(nm)
}
fs.SetRemotes(b.netMap.Domain, driveRemotes, b.newDriveTransport())
fs.SetRemotes(nm.Domain, driveRemotes, b.newDriveTransport())
}
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 {
peerID := p.ID()
url := fmt.Sprintf("%s/%s", peerAPIBase(nm, p), taildrivePrefix[1:])
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: url,
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
b.mu.Lock()
latestNetMap := b.netMap
b.mu.Unlock()
idx, found := slices.BinarySearchFunc(latestNetMap.Peers, peerID, func(candidate tailcfg.NodeView, id tailcfg.NodeID) int {
return cmp.Compare(candidate.ID(), id)
})
if !found {
cn := b.currentNode()
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: Available(): peer %s not found", peerKey)
return false
}
peer := latestNetMap.Peers[idx]
// 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
}
if b.currentNode().PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: Available(): peer %s PeerAPI unreachable", peerKey)
return false
}
// Check that the peer is allowed to share with us.
addresses := peer.Addresses()
for _, p := range addresses.All() {
capsMap := b.PeerCaps(p.Addr())
if capsMap.HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
return true
}
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
},
})
}
return driveRemotes
}
// responseBodyWrapper wraps an io.ReadCloser and stores
// the number of bytesRead.
type responseBodyWrapper struct {
io.ReadCloser
logVerbose bool
bytesRx int64
bytesTx int64
log logger.Logf
method string
statusCode int
contentType string
fileExtension string
shareNodeKey string
selfNodeKey string
contentLength int64
}
// logAccess logs the taildrive: access: log line. If the logger is nil,
// the log will not be written.
func (rbw *responseBodyWrapper) logAccess(err string) {
if rbw.log == nil {
return
}
// Some operating systems create and copy lots of 0 length hidden files for
// tracking various states. Omit these to keep logs from being too verbose.
if rbw.logVerbose || rbw.contentLength > 0 {
levelPrefix := ""
if rbw.logVerbose {
levelPrefix = "[v1] "
}
rbw.log(
"%staildrive: access: %s from %s to %s: status-code=%d ext=%q content-type=%q content-length=%.f tx=%.f rx=%.f err=%q",
levelPrefix,
rbw.method,
rbw.selfNodeKey,
rbw.shareNodeKey,
rbw.statusCode,
rbw.fileExtension,
rbw.contentType,
roundTraffic(rbw.contentLength),
roundTraffic(rbw.bytesTx), roundTraffic(rbw.bytesRx), err)
}
}
// Read implements the io.Reader interface.
func (rbw *responseBodyWrapper) Read(b []byte) (int, error) {
n, err := rbw.ReadCloser.Read(b)
rbw.bytesRx += int64(n)
if err != nil && !errors.Is(err, io.EOF) {
rbw.logAccess(err.Error())
}
return n, err
}
// Close implements the io.Close interface.
func (rbw *responseBodyWrapper) Close() error {
err := rbw.ReadCloser.Close()
var errStr string
if err != nil {
errStr = err.Error()
}
rbw.logAccess(errStr)
return err
}
// driveTransport is an http.RoundTripper that wraps
// b.Dialer().PeerAPITransport() with metrics tracking.
type driveTransport struct {
b *LocalBackend
tr http.RoundTripper
}
func (b *LocalBackend) newDriveTransport() *driveTransport {
return &driveTransport{
b: b,
tr: b.Dialer().PeerAPITransport(),
}
}
func (dt *driveTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Some WebDAV clients include origin and refer headers, which peerapi does
// not like. Remove them.
req.Header.Del("origin")
req.Header.Del("referer")
bw := &requestBodyWrapper{}
if req.Body != nil {
bw.ReadCloser = req.Body
req.Body = bw
}
resp, err := dt.tr.RoundTrip(req)
if err != nil {
return nil, err
}
contentType := "unknown"
if ct := req.Header.Get("Content-Type"); ct != "" {
contentType = ct
}
dt.b.mu.Lock()
selfNodeKey := dt.b.currentNode().Self().Key().ShortString()
dt.b.mu.Unlock()
n, _, ok := dt.b.WhoIs("tcp", netip.MustParseAddrPort(req.URL.Host))
shareNodeKey := "unknown"
if ok {
shareNodeKey = string(n.Key().ShortString())
}
rbw := responseBodyWrapper{
log: dt.b.logf,
logVerbose: req.Method != httpm.GET && req.Method != httpm.PUT, // other requests like PROPFIND are quite chatty, so we log those at verbose level
method: req.Method,
bytesTx: int64(bw.bytesRead),
selfNodeKey: selfNodeKey,
shareNodeKey: shareNodeKey,
contentType: contentType,
contentLength: resp.ContentLength,
fileExtension: parseDriveFileExtensionForLog(req.URL.Path),
statusCode: resp.StatusCode,
ReadCloser: resp.Body,
}
if resp.StatusCode >= 400 {
// in case of error response, just log immediately
rbw.logAccess("")
} else {
resp.Body = &rbw
}
return resp, nil
}

30
vendor/tailscale.com/ipn/ipnlocal/drive_tomove.go generated vendored Normal file
View File

@@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// This is the Taildrive stuff that should ideally be registered in init only when
// the ts_omit_drive is not set, but for transition reasons is currently (2025-09-08)
// always defined, as we work to pull it out of LocalBackend.
package ipnlocal
import "tailscale.com/tailcfg"
const (
// DriveLocalPort is the port on which the Taildrive listens for location
// connections on quad 100.
DriveLocalPort = 8080
)
// DriveSharingEnabled reports whether sharing to remote nodes via Taildrive is
// enabled. This is currently based on checking for the drive:share node
// attribute.
func (b *LocalBackend) DriveSharingEnabled() bool {
return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveShare)
}
// 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)
}

View File

@@ -6,12 +6,14 @@ package ipnlocal
import (
"time"
"tailscale.com/control/controlclient"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/util/eventbus"
)
// For extra defense-in-depth, when we're testing expired nodes we check
@@ -40,14 +42,22 @@ type expiryManager struct {
logf logger.Logf
clock tstime.Clock
eventClient *eventbus.Client
}
func newExpiryManager(logf logger.Logf) *expiryManager {
return &expiryManager{
func newExpiryManager(logf logger.Logf, bus *eventbus.Bus) *expiryManager {
em := &expiryManager{
previouslyExpired: map[tailcfg.StableNodeID]bool{},
logf: logf,
clock: tstime.StdClock{},
}
em.eventClient = bus.Client("ipnlocal.expiryManager")
eventbus.SubscribeFunc(em.eventClient, func(ct controlclient.ControlTime) {
em.onControlTime(ct.Value)
})
return em
}
// onControlTime is called whenever we receive a new timestamp from the control
@@ -218,6 +228,8 @@ func (em *expiryManager) nextPeerExpiry(nm *netmap.NetworkMap, localNow time.Tim
return nextExpiry
}
func (em *expiryManager) close() { em.eventClient.Close() }
// ControlNow estimates the current time on the control server, calculated as
// localNow + the delta between local and control server clocks as recorded
// when the LocalBackend last received a time message from the control server.

621
vendor/tailscale.com/ipn/ipnlocal/extension_host.go generated vendored Normal file
View File

@@ -0,0 +1,621 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/execqueue"
"tailscale.com/util/mak"
"tailscale.com/util/testenv"
)
// ExtensionHost is a bridge between the [LocalBackend] and the registered [ipnext.Extension]s.
// It implements [ipnext.Host] and is safe for concurrent use.
//
// A nil pointer to [ExtensionHost] is a valid, no-op extension host which is primarily used in tests
// that instantiate [LocalBackend] directly without using [NewExtensionHost].
//
// The [LocalBackend] is not required to hold its mutex when calling the host's methods,
// but it typically does so either to prevent changes to its state (for example, the current profile)
// while callbacks are executing, or because it calls the host's methods as part of a larger operation
// that requires the mutex to be held.
//
// Extensions might invoke the host's methods either from callbacks triggered by the [LocalBackend],
// or in a response to external events. Some methods can be called by both the extensions and the backend.
//
// As a general rule, the host cannot assume anything about the current state of the [LocalBackend]'s
// internal mutex on entry to its methods, and therefore cannot safely call [LocalBackend] methods directly.
//
// The following are typical and supported patterns:
// - LocalBackend notifies the host about an event, such as a change in the current profile.
// The host invokes callbacks registered by Extensions, forwarding the event arguments to them.
// If necessary, the host can also update its own state for future use.
// - LocalBackend requests information from the host, such as the effective [ipnauth.AuditLogFunc]
// or the [ipn.LoginProfile] to use when no GUI/CLI client is connected. Typically, [LocalBackend]
// provides the required context to the host, and the host returns the result to [LocalBackend]
// after forwarding the request to the extensions.
// - Extension invokes the host's method to perform an action, such as switching to the "best" profile
// in response to a change in the device's state. Since the host does not know whether the [LocalBackend]'s
// internal mutex is held, it cannot invoke any methods on the [LocalBackend] directly and must instead
// do so asynchronously, such as by using [ExtensionHost.enqueueBackendOperation].
// - Extension requests information from the host, such as the effective [ipnauth.AuditLogFunc]
// or the current [ipn.LoginProfile]. Since the host cannot invoke any methods on the [LocalBackend] directly,
// it should maintain its own view of the current state, updating it when the [LocalBackend] notifies it
// about a change or event.
//
// To safeguard against adopting incorrect or risky patterns, the host does not store [LocalBackend] in its fields
// and instead provides [ExtensionHost.enqueueBackendOperation]. Additionally, to make it easier to test extensions
// and to further reduce the risk of accessing unexported methods or fields of [LocalBackend], the host interacts
// with it via the [Backend] interface.
type ExtensionHost struct {
b Backend
hooks ipnext.Hooks
logf logger.Logf // prefixed with "ipnext:"
// allExtensions holds the extensions in the order they were registered,
// including those that have not yet attempted initialization or have failed to initialize.
allExtensions []ipnext.Extension
// initOnce is used to ensure that the extensions are initialized only once,
// even if [extensionHost.Init] is called multiple times.
initOnce sync.Once
initDone atomic.Bool
// shutdownOnce is like initOnce, but for [ExtensionHost.Shutdown].
shutdownOnce sync.Once
// workQueue maintains execution order for asynchronous operations requested by extensions.
// It is always an [execqueue.ExecQueue] except in some tests.
workQueue execQueue
// doEnqueueBackendOperation adds an asynchronous [LocalBackend] operation to the workQueue.
doEnqueueBackendOperation func(func(Backend))
shuttingDown atomic.Bool
extByType sync.Map // reflect.Type -> ipnext.Extension
// mu protects the following fields.
// It must not be held when calling [LocalBackend] methods
// or when invoking callbacks registered by extensions.
mu sync.Mutex
// initialized is whether the host and extensions have been fully initialized.
initialized atomic.Bool
// activeExtensions is a subset of allExtensions that have been initialized and are ready to use.
activeExtensions []ipnext.Extension
// extensionsByName are the extensions indexed by their names.
// They are not necessarily initialized (in activeExtensions) yet.
extensionsByName map[string]ipnext.Extension
// postInitWorkQueue is a queue of functions to be executed
// by the workQueue after all extensions have been initialized.
postInitWorkQueue []func(Backend)
// currentProfile is a read-only view of the currently used profile.
// The view is always Valid, but might be of an empty, non-persisted profile.
currentProfile ipn.LoginProfileView
// currentPrefs is a read-only view of the current profile's [ipn.Prefs]
// with any private keys stripped. It is always Valid.
currentPrefs ipn.PrefsView
}
// Backend is a subset of [LocalBackend] methods that are used by [ExtensionHost].
// It is primarily used for testing.
type Backend interface {
// SwitchToBestProfile switches to the best profile for the current state of the system.
// The reason indicates why the profile is being switched.
SwitchToBestProfile(reason string)
SendNotify(ipn.Notify)
NodeBackend() ipnext.NodeBackend
ipnext.SafeBackend
}
// NewExtensionHost returns a new [ExtensionHost] which manages registered extensions for the given backend.
// The extensions are instantiated, but are not initialized until [ExtensionHost.Init] is called.
// It returns an error if instantiating any extension fails.
func NewExtensionHost(logf logger.Logf, b Backend) (*ExtensionHost, error) {
return newExtensionHost(logf, b)
}
func NewExtensionHostForTest(logf logger.Logf, b Backend, overrideExts ...*ipnext.Definition) (*ExtensionHost, error) {
if !testenv.InTest() {
panic("use outside of test")
}
return newExtensionHost(logf, b, overrideExts...)
}
// newExtensionHost is the shared implementation of [NewExtensionHost] and
// [NewExtensionHostForTest].
//
// If overrideExts is non-nil, the registered extensions are ignored and the
// provided extensions are used instead. Overriding extensions is primarily used
// for testing.
func newExtensionHost(logf logger.Logf, b Backend, overrideExts ...*ipnext.Definition) (_ *ExtensionHost, err error) {
host := &ExtensionHost{
b: b,
logf: logger.WithPrefix(logf, "ipnext: "),
workQueue: &execqueue.ExecQueue{},
// The host starts with an empty profile and default prefs.
// We'll update them once [profileManager] notifies us of the initial profile.
currentProfile: zeroProfile,
currentPrefs: defaultPrefs,
}
// All operations on the backend must be executed asynchronously by the work queue.
// DO NOT retain a direct reference to the backend in the host.
// See the docstring for [ExtensionHost] for more details.
host.doEnqueueBackendOperation = func(f func(Backend)) {
if f == nil {
panic("nil backend operation")
}
host.workQueue.Add(func() { f(b) })
}
// Use registered extensions.
extDef := ipnext.Extensions()
if overrideExts != nil {
// Use the provided, potentially empty, overrideExts
// instead of the registered ones.
extDef = slices.Values(overrideExts)
}
for d := range extDef {
ext, err := d.MakeExtension(logf, b)
if errors.Is(err, ipnext.SkipExtension) {
// The extension wants to be skipped.
host.logf("%q: %v", d.Name(), err)
continue
} else if err != nil {
return nil, fmt.Errorf("failed to create %q extension: %v", d.Name(), err)
}
host.allExtensions = append(host.allExtensions, ext)
if d.Name() != ext.Name() {
return nil, fmt.Errorf("extension name %q does not match the registered name %q", ext.Name(), d.Name())
}
if _, ok := host.extensionsByName[ext.Name()]; ok {
return nil, fmt.Errorf("duplicate extension name %q", ext.Name())
} else {
mak.Set(&host.extensionsByName, ext.Name(), ext)
}
typ := reflect.TypeOf(ext)
if _, ok := host.extByType.Load(typ); ok {
if _, ok := ext.(interface{ PermitDoubleRegister() }); !ok {
return nil, fmt.Errorf("duplicate extension type %T", ext)
}
}
host.extByType.Store(typ, ext)
}
return host, nil
}
func (h *ExtensionHost) NodeBackend() ipnext.NodeBackend {
if h == nil {
return nil
}
return h.b.NodeBackend()
}
// Init initializes the host and the extensions it manages.
func (h *ExtensionHost) Init() {
if h != nil {
h.initOnce.Do(h.init)
}
}
var zeroHooks ipnext.Hooks
func (h *ExtensionHost) Hooks() *ipnext.Hooks {
if h == nil {
return &zeroHooks
}
return &h.hooks
}
func (h *ExtensionHost) init() {
defer h.initDone.Store(true)
// Initialize the extensions in the order they were registered.
for _, ext := range h.allExtensions {
// Do not hold the lock while calling [ipnext.Extension.Init].
// Extensions call back into the host to register their callbacks,
// and that would cause a deadlock if the h.mu is already held.
if err := ext.Init(h); err != nil {
// As per the [ipnext.Extension] interface, failures to initialize
// an extension are never fatal. The extension is simply skipped.
//
// But we handle [ipnext.SkipExtension] differently for nicer logging
// if the extension wants to be skipped and not actually failing.
if errors.Is(err, ipnext.SkipExtension) {
h.logf("%q: %v", ext.Name(), err)
} else {
h.logf("%q init failed: %v", ext.Name(), err)
}
continue
}
// Update the initialized extensions lists as soon as the extension is initialized.
// We'd like to make them visible to other extensions that are initialized later.
h.mu.Lock()
h.activeExtensions = append(h.activeExtensions, ext)
h.mu.Unlock()
}
// 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))
h.mu.Unlock()
h.logf("active extensions: %v", strings.Join(extensionNames, ", "))
// Additional init steps that need to be performed after all extensions have been initialized.
h.mu.Lock()
wq := h.postInitWorkQueue
h.postInitWorkQueue = nil
h.initialized.Store(true)
h.mu.Unlock()
// Enqueue work that was requested and deferred during initialization.
h.doEnqueueBackendOperation(func(b Backend) {
for _, f := range wq {
f(b)
}
})
}
// Extensions implements [ipnext.Host].
func (h *ExtensionHost) Extensions() ipnext.ExtensionServices {
// Currently, [ExtensionHost] implements [ExtensionServices] directly.
// We might want to extract it to a separate type in the future.
return h
}
// FindExtensionByName implements [ipnext.ExtensionServices]
// and is also used by the [LocalBackend].
// It returns nil if the extension is not found.
func (h *ExtensionHost) FindExtensionByName(name string) any {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
return h.extensionsByName[name]
}
// extensionIfaceType is the runtime type of the [ipnext.Extension] interface.
var extensionIfaceType = reflect.TypeFor[ipnext.Extension]()
// GetExt returns the extension of type T registered with lb.
// If lb is nil or the extension is not found, it returns zero, false.
func GetExt[T ipnext.Extension](lb *LocalBackend) (_ T, ok bool) {
var zero T
if lb == nil {
return zero, false
}
if ext, ok := lb.extHost.extensionOfType(reflect.TypeFor[T]()); ok {
return ext.(T), true
}
return zero, false
}
func (h *ExtensionHost) extensionOfType(t reflect.Type) (_ ipnext.Extension, ok bool) {
if h == nil {
return nil, false
}
if v, ok := h.extByType.Load(t); ok {
return v.(ipnext.Extension), true
}
return nil, false
}
// FindMatchingExtension implements [ipnext.ExtensionServices]
// and is also used by the [LocalBackend].
func (h *ExtensionHost) FindMatchingExtension(target any) bool {
if h == nil {
return false
}
if target == nil {
panic("ipnext: target cannot be nil")
}
val := reflect.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflect.Ptr || val.IsNil() {
panic("ipnext: target must be a non-nil pointer")
}
targetType := typ.Elem()
if targetType.Kind() != reflect.Interface && !targetType.Implements(extensionIfaceType) {
panic("ipnext: *target must be interface or implement ipnext.Extension")
}
h.mu.Lock()
defer h.mu.Unlock()
for _, ext := range h.activeExtensions {
if reflect.TypeOf(ext).AssignableTo(targetType) {
val.Elem().Set(reflect.ValueOf(ext))
return true
}
}
return false
}
// Profiles implements [ipnext.Host].
func (h *ExtensionHost) Profiles() ipnext.ProfileServices {
// Currently, [ExtensionHost] implements [ipnext.ProfileServices] directly.
// We might want to extract it to a separate type in the future.
return h
}
// CurrentProfileState implements [ipnext.ProfileServices].
func (h *ExtensionHost) CurrentProfileState() (ipn.LoginProfileView, ipn.PrefsView) {
if h == nil {
return zeroProfile, defaultPrefs
}
h.mu.Lock()
defer h.mu.Unlock()
return h.currentProfile, h.currentPrefs
}
// CurrentPrefs implements [ipnext.ProfileServices].
func (h *ExtensionHost) CurrentPrefs() ipn.PrefsView {
_, prefs := h.CurrentProfileState()
return prefs
}
// SwitchToBestProfileAsync implements [ipnext.ProfileServices].
func (h *ExtensionHost) SwitchToBestProfileAsync(reason string) {
if h == nil {
return
}
h.enqueueBackendOperation(func(b Backend) {
b.SwitchToBestProfile(reason)
})
}
// SendNotifyAsync implements [ipnext.Host].
func (h *ExtensionHost) SendNotifyAsync(n ipn.Notify) {
if h == nil {
return
}
h.enqueueBackendOperation(func(b Backend) {
b.SendNotify(n)
})
}
// NotifyProfileChange invokes registered profile state change callbacks
// and updates the current profile and prefs in the host.
// It strips private keys from the [ipn.Prefs] before preserving
// or passing them to the callbacks.
func (h *ExtensionHost) NotifyProfileChange(profile ipn.LoginProfileView, prefs ipn.PrefsView, sameNode bool) {
if !h.active() {
return
}
h.mu.Lock()
// Strip private keys from the prefs before preserving or passing them to the callbacks.
// Extensions should not need them (unless proven otherwise in the future),
// and this is a good way to ensure that they won't accidentally leak them.
prefs = stripKeysFromPrefs(prefs)
// Update the current profile and prefs in the host,
// so we can provide them to the extensions later if they ask.
h.currentPrefs = prefs
h.currentProfile = profile
h.mu.Unlock()
for _, cb := range h.hooks.ProfileStateChange {
cb(profile, prefs, sameNode)
}
}
// NotifyProfilePrefsChanged invokes registered profile state change callbacks,
// and updates the current profile and prefs in the host.
// It strips private keys from the [ipn.Prefs] before preserving or using them.
func (h *ExtensionHost) NotifyProfilePrefsChanged(profile ipn.LoginProfileView, oldPrefs, newPrefs ipn.PrefsView) {
if !h.active() {
return
}
h.mu.Lock()
// Strip private keys from the prefs before preserving or passing them to the callbacks.
// Extensions should not need them (unless proven otherwise in the future),
// and this is a good way to ensure that they won't accidentally leak them.
newPrefs = stripKeysFromPrefs(newPrefs)
// Update the current profile and prefs in the host,
// so we can provide them to the extensions later if they ask.
h.currentPrefs = newPrefs
h.currentProfile = profile
// Get the callbacks to be invoked.
h.mu.Unlock()
for _, cb := range h.hooks.ProfileStateChange {
cb(profile, newPrefs, true)
}
}
func (h *ExtensionHost) active() bool {
return h != nil && !h.shuttingDown.Load()
}
// DetermineBackgroundProfile returns a read-only view of the profile
// used when no GUI/CLI client is connected, using background profile
// resolvers registered by extensions.
//
// It returns an invalid view if Tailscale should not run in the background
// and instead disconnect until a GUI/CLI client connects.
//
// As of 2025-02-07, this is only used on Windows.
func (h *ExtensionHost) DetermineBackgroundProfile(profiles ipnext.ProfileStore) ipn.LoginProfileView {
if !h.active() {
return ipn.LoginProfileView{}
}
// TODO(nickkhyl): check if the returned profile is allowed on the device,
// such as when [syspolicy.Tailnet] policy setting requires a specific Tailnet.
// See tailscale/corp#26249.
// Attempt to resolve the background profile using the registered
// background profile resolvers (e.g., [ipn/desktop.desktopSessionsExt] on Windows).
for _, resolver := range h.hooks.BackgroundProfileResolvers {
if profile := resolver(profiles); profile.Valid() {
return profile
}
}
// Otherwise, switch to an empty profile and disconnect Tailscale
// until a GUI or CLI client connects.
return ipn.LoginProfileView{}
}
// NotifyNewControlClient invokes all registered control client callbacks.
// It returns callbacks to be executed when the control client shuts down.
func (h *ExtensionHost) NotifyNewControlClient(cc controlclient.Client, profile ipn.LoginProfileView) (ccShutdownCbs []func()) {
if !h.active() {
return nil
}
for _, cb := range h.hooks.NewControlClient {
if shutdown := cb(cc, profile); shutdown != nil {
ccShutdownCbs = append(ccShutdownCbs, shutdown)
}
}
return ccShutdownCbs
}
// AuditLogger returns a function that reports an auditable action
// to all registered audit loggers. It fails if any of them returns an error,
// indicating that the action cannot be logged and must not be performed.
//
// It implements [ipnext.Host], but is also used by the [LocalBackend].
//
// The returned function closes over the current state of the host and extensions,
// which typically includes the current profile and the audit loggers registered by extensions.
// It must not be persisted outside of the auditable action context.
func (h *ExtensionHost) AuditLogger() ipnauth.AuditLogFunc {
if !h.active() {
return func(tailcfg.ClientAuditAction, string) error { return nil }
}
loggers := make([]ipnauth.AuditLogFunc, 0, len(h.hooks.AuditLoggers))
for _, provider := range h.hooks.AuditLoggers {
loggers = append(loggers, provider())
}
return func(action tailcfg.ClientAuditAction, details string) error {
// Log auditable actions to the host's log regardless of whether
// the audit loggers are available or not.
h.logf("auditlog: %v: %v", action, details)
// Invoke all registered audit loggers and collect errors.
// If any of them returns an error, the action is denied.
var errs []error
for _, logger := range loggers {
if err := logger(action, details); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
}
// Shutdown shuts down the extension host and all initialized extensions.
func (h *ExtensionHost) Shutdown() {
if h == nil {
return
}
// Ensure that the init function has completed before shutting down,
// or prevent any further init calls from happening.
h.initOnce.Do(func() {})
h.shutdownOnce.Do(h.shutdown)
}
func (h *ExtensionHost) shutdown() {
h.shuttingDown.Store(true)
// Prevent any queued but not yet started operations from running,
// block new operations from being enqueued, and wait for the
// currently executing operation (if any) to finish.
h.shutdownWorkQueue()
// Invoke shutdown callbacks registered by extensions.
h.shutdownExtensions()
}
func (h *ExtensionHost) shutdownWorkQueue() {
h.workQueue.Shutdown()
var ctx context.Context
if testenv.InTest() {
// In tests, we'd like to wait indefinitely for the current operation to finish,
// mostly to help avoid flaky tests. Test runners can be pretty slow.
ctx = context.Background()
} else {
// In prod, however, we want to avoid blocking indefinitely.
// The 5s timeout is somewhat arbitrary; LocalBackend operations
// should not take that long.
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
}
// Since callbacks are invoked synchronously, this will also wait
// for in-flight callbacks associated with those operations to finish.
if err := h.workQueue.Wait(ctx); err != nil {
h.logf("work queue shutdown failed: %v", err)
}
}
func (h *ExtensionHost) shutdownExtensions() {
h.mu.Lock()
extensions := h.activeExtensions
h.mu.Unlock()
// h.mu must not be held while shutting down extensions.
// Extensions might call back into the host and that would cause
// a deadlock if the h.mu is already held.
//
// Shutdown is called in the reverse order of Init.
for _, ext := range slices.Backward(extensions) {
if err := ext.Shutdown(); err != nil {
// Extension shutdown errors are never fatal, but we log them for debugging purposes.
h.logf("%q: shutdown callback failed: %v", ext.Name(), err)
}
}
}
// enqueueBackendOperation enqueues a function to perform an operation on the [Backend].
// If the host has not yet been initialized (e.g., when called from an extension's Init method),
// the operation is deferred until after the host and all extensions have completed initialization.
// It panics if the f is nil.
func (h *ExtensionHost) enqueueBackendOperation(f func(Backend)) {
if h == nil {
return
}
if f == nil {
panic("nil backend operation")
}
h.mu.Lock() // protects h.initialized and h.postInitWorkQueue
defer h.mu.Unlock()
if h.initialized.Load() {
h.doEnqueueBackendOperation(f)
} else {
h.postInitWorkQueue = append(h.postInitWorkQueue, f)
}
}
// execQueue is an ordered asynchronous queue for executing functions.
// It is implemented by [execqueue.ExecQueue]. The interface is used
// to allow testing with a mock implementation.
type execQueue interface {
Add(func())
Shutdown()
Wait(context.Context) error
}

48
vendor/tailscale.com/ipn/ipnlocal/hwattest.go generated vendored Normal file
View File

@@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tpm
package ipnlocal
import (
"errors"
"tailscale.com/feature"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
)
func init() {
feature.HookGenerateAttestationKeyIfEmpty.Set(generateAttestationKeyIfEmpty)
}
// generateAttestationKeyIfEmpty generates a new hardware attestation key if
// none exists. It returns true if a new key was generated and stored in
// p.AttestationKey.
func generateAttestationKeyIfEmpty(p *persist.Persist, logf logger.Logf) (bool, error) {
// attempt to generate a new hardware attestation key if none exists
var ak key.HardwareAttestationKey
if p != nil {
ak = p.AttestationKey
}
if ak == nil || ak.IsZero() {
var err error
ak, err = key.NewHardwareAttestationKey()
if err != nil {
if !errors.Is(err, key.ErrUnsupported) {
logf("failed to create hardware attestation key: %v", err)
}
} else if ak != nil {
logf("using new hardware attestation key: %v", ak.Public())
if p == nil {
p = &persist.Persist{}
}
p.AttestationKey = ak
return true, nil
}
}
return false, nil
}

File diff suppressed because it is too large Load Diff

74
vendor/tailscale.com/ipn/ipnlocal/netstack.go generated vendored Normal file
View File

@@ -0,0 +1,74 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_netstack
package ipnlocal
import (
"net"
"net/netip"
"time"
"gvisor.dev/gvisor/pkg/tcpip"
"tailscale.com/types/ptr"
)
// TCPHandlerForDst returns a TCP handler for connections to dst, or nil if
// no handler is needed. It also returns a list of TCP socket options to
// apply to the socket before calling the handler.
// TCPHandlerForDst is called both for connections to our node's local IP
// as well as to the service IP (quad 100).
func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c net.Conn) error, opts []tcpip.SettableSocketOption) {
// First handle internal connections to the service IP
hittingServiceIP := dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6
if hittingServiceIP {
switch dst.Port() {
case 80:
// TODO(mpminardi): do we want to show an error message if the web client
// has been disabled instead of the more "basic" web UI?
if b.ShouldRunWebClient() {
return b.handleWebClientConn, opts
}
return b.HandleQuad100Port80Conn, opts
case DriveLocalPort:
return b.handleDriveConn, opts
}
}
if f, ok := hookServeTCPHandlerForVIPService.GetOk(); ok {
if handler := f(b, dst, src); handler != nil {
return handler, opts
}
}
// Then handle external connections to the local IP.
if !b.isLocalIP(dst.Addr()) {
return nil, nil
}
if dst.Port() == 22 && b.ShouldRunSSH() {
// Use a higher keepalive idle time for SSH connections, as they are
// typically long lived and idle connections are more likely to be
// intentional. Ideally we would turn this off entirely, but we can't
// 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)))
return b.handleSSHConn, opts
}
// TODO(will,sonia): allow customizing web client port ?
if dst.Port() == webClientPort && b.ShouldExposeRemoteWebClient() {
return b.handleWebClientConn, opts
}
if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port {
return func(c net.Conn) error {
b.handlePeerAPIConn(src, dst, c)
return nil
}, opts
}
if f, ok := hookTCPHandlerForServe.GetOk(); ok {
if handler := f(b, dst.Port(), src, nil); handler != nil {
return handler, opts
}
}
return nil, nil
}

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package ipnlocal
import (
@@ -21,6 +23,7 @@ import (
"slices"
"time"
"tailscale.com/health"
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -52,10 +55,68 @@ var (
type tkaState struct {
profile ipn.ProfileID
authority *tka.Authority
storage *tka.FS
storage tka.CompactableChonk
filtered []ipnstate.TKAPeer
}
func (b *LocalBackend) initTKALocked() error {
cp := b.pm.CurrentProfile()
if cp.ID() == "" {
b.tka = nil
return nil
}
if b.tka != nil {
if b.tka.profile == cp.ID() {
// Already initialized.
return nil
}
// As we're switching profiles, we need to reset the TKA to nil.
b.tka = nil
}
root := b.TailscaleVarRoot()
if root == "" {
b.tka = nil
b.logf("cannot fetch existing TKA state; no state directory for network-lock")
return nil
}
chonkDir := b.chonkPathLocked()
if _, err := os.Stat(chonkDir); err == nil {
// The directory exists, which means network-lock has been initialized.
storage, err := tka.ChonkDir(chonkDir)
if err != nil {
return fmt.Errorf("opening tailchonk: %v", err)
}
authority, err := tka.Open(storage)
if err != nil {
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())
}
return nil
}
// noNetworkLockStateDirWarnable 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{
Code: "no-tailnet-lock-state-dir",
Title: "No statedir for Tailnet Lock",
Severity: health.SeverityMedium,
Text: health.StaticMessage(healthmsg.InMemoryTailnetLockState),
})
// tkaFilterNetmapLocked checks the signatures on each node key, dropping
// nodes from the netmap whose signature does not verify.
//
@@ -239,8 +300,11 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
return nil
}
if b.tka != nil || nm.TKAEnabled {
b.logf("tkaSyncIfNeeded: enabled=%v, head=%v", nm.TKAEnabled, nm.TKAHead)
isEnabled := b.tka != nil
wantEnabled := nm.TKAEnabled
if isEnabled || wantEnabled {
b.logf("tkaSyncIfNeeded: isEnabled=%t, wantEnabled=%t, head=%v", isEnabled, wantEnabled, nm.TKAHead)
}
ourNodeKey, ok := prefs.Persist().PublicNodeKeyOK()
@@ -248,8 +312,6 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
return errors.New("tkaSyncIfNeeded: no node key in prefs")
}
isEnabled := b.tka != nil
wantEnabled := nm.TKAEnabled
didJustEnable := false
if isEnabled != wantEnabled {
var ourHead tka.AUMHash
@@ -294,25 +356,18 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
if err := b.tkaSyncLocked(ourNodeKey); err != nil {
return fmt.Errorf("tka sync: %w", err)
}
// Try to compact the TKA state, to avoid unbounded storage on nodes.
//
// 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 {
return fmt.Errorf("tka compact: %w", err)
}
}
return nil
}
func toSyncOffer(head string, ancestors []string) (tka.SyncOffer, error) {
var out tka.SyncOffer
if err := out.Head.UnmarshalText([]byte(head)); err != nil {
return tka.SyncOffer{}, fmt.Errorf("head.UnmarshalText: %v", err)
}
out.Ancestors = make([]tka.AUMHash, len(ancestors))
for i, a := range ancestors {
if err := out.Ancestors[i].UnmarshalText([]byte(a)); err != nil {
return tka.SyncOffer{}, fmt.Errorf("ancestor[%d].UnmarshalText: %v", i, err)
}
}
return out, nil
}
// tkaSyncLocked synchronizes TKA state with control. b.mu must be held
// and tka must be initialized. b.mu will be stepped out of (and back into)
// during network RPCs.
@@ -330,7 +385,7 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
if err != nil {
return fmt.Errorf("offer RPC: %w", err)
}
controlOffer, err := toSyncOffer(offerResp.Head, offerResp.Ancestors)
controlOffer, err := tka.ToSyncOffer(offerResp.Head, offerResp.Ancestors)
if err != nil {
return fmt.Errorf("control offer: %v", err)
}
@@ -393,7 +448,7 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
// b.mu must be held & TKA must be initialized.
func (b *LocalBackend) tkaApplyDisablementLocked(secret []byte) error {
if b.tka.authority.ValidDisablement(secret) {
if err := os.RemoveAll(b.chonkPathLocked()); err != nil {
if err := b.tka.storage.RemoveAll(); err != nil {
return err
}
b.tka = nil
@@ -415,10 +470,6 @@ func (b *LocalBackend) chonkPathLocked() string {
//
// b.mu must be held.
func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, persist persist.PersistView) error {
if err := b.CanSupportNetworkLock(); err != nil {
return err
}
var genesis tka.AUM
if err := genesis.Unserialize(g); err != nil {
return fmt.Errorf("reading genesis: %v", err)
@@ -437,19 +488,21 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
}
}
chonkDir := b.chonkPathLocked()
if err := os.Mkdir(filepath.Dir(chonkDir), 0755); err != nil && !os.IsExist(err) {
return fmt.Errorf("creating chonk root dir: %v", err)
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")
storage = tka.ChonkMem()
} else {
chonkDir := b.chonkPathLocked()
chonk, err := tka.ChonkDir(chonkDir)
if err != nil {
return fmt.Errorf("chonk: %v", err)
}
storage = chonk
}
if err := os.Mkdir(chonkDir, 0755); err != nil && !os.IsExist(err) {
return fmt.Errorf("mkdir: %v", err)
}
chonk, err := tka.ChonkDir(chonkDir)
if err != nil {
return fmt.Errorf("chonk: %v", err)
}
authority, err := tka.Bootstrap(chonk, genesis)
authority, err := tka.Bootstrap(storage, genesis)
if err != nil {
return fmt.Errorf("tka bootstrap: %v", err)
}
@@ -457,29 +510,11 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
b.tka = &tkaState{
profile: b.pm.CurrentProfile().ID(),
authority: authority,
storage: chonk,
storage: storage,
}
return nil
}
// CanSupportNetworkLock returns nil if tailscaled is able to operate
// a local tailnet key authority (and hence enforce network lock).
func (b *LocalBackend) CanSupportNetworkLock() error {
if b.tka != nil {
// If the TKA is being used, it is supported.
return nil
}
if b.TailscaleVarRoot() == "" {
return errors.New("network-lock is not supported in this configuration, try setting --statedir")
}
// There's a var root (aka --statedir), so if network lock gets
// initialized we have somewhere to store our AUMs. That's all
// we need.
return nil
}
// NetworkLockStatus returns a structure describing the state of the
// tailnet key authority, if any.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
@@ -516,9 +551,10 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
var selfAuthorized bool
nodeKeySignature := &tka.NodeKeySignature{}
if b.netMap != nil {
selfAuthorized = b.tka.authority.NodeKeyAuthorized(b.netMap.SelfNode.Key(), b.netMap.SelfNode.KeySignature().AsSlice()) == nil
if err := nodeKeySignature.Unserialize(b.netMap.SelfNode.KeySignature().AsSlice()); err != nil {
nm := b.currentNode().NetMap()
if nm != nil {
selfAuthorized = b.tka.authority.NodeKeyAuthorized(nm.SelfNode.Key(), nm.SelfNode.KeySignature().AsSlice()) == nil
if err := nodeKeySignature.Unserialize(nm.SelfNode.KeySignature().AsSlice()); err != nil {
b.logf("failed to decode self node key signature: %v", err)
}
}
@@ -527,6 +563,7 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
outKeys := make([]ipnstate.TKAKey, len(keys))
for i, k := range keys {
outKeys[i] = ipnstate.TKAKey{
Kind: k.Kind.String(),
Key: key.NLPublicFromEd25519Unsafe(k.Public),
Metadata: k.Meta,
Votes: k.Votes,
@@ -539,9 +576,9 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
var visible []*ipnstate.TKAPeer
if b.netMap != nil {
visible = make([]*ipnstate.TKAPeer, len(b.netMap.Peers))
for i, p := range b.netMap.Peers {
if nm != nil {
visible = make([]*ipnstate.TKAPeer, len(nm.Peers))
for i, p := range nm.Peers {
s := tkaStateFromPeer(p)
visible[i] = &s
}
@@ -593,24 +630,16 @@ func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
// 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 {
if err := b.CanSupportNetworkLock(); err != nil {
return err
}
var ourNodeKey key.NodePublic
var nlPriv key.NLPrivate
b.mu.Lock()
if !b.capTailnetLock {
b.mu.Unlock()
return errors.New("not permitted to enable tailnet lock")
}
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
ourNodeKey = p.Persist().PublicNodeKey()
nlPriv = p.Persist().NetworkLockKey()
}
b.mu.Unlock()
if ourNodeKey.IsZero() || nlPriv.IsZero() {
return errors.New("no node-key: is tailscale logged in?")
}
@@ -624,7 +653,7 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
// We use an in-memory tailchonk because we don't want to commit to
// the filesystem until we've finished the initialization sequence,
// just in case something goes wrong.
_, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{
_, 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:
@@ -652,7 +681,7 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
// Our genesis AUM was accepted but before Control turns on enforcement of
// 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 loose
// 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.
sigs := make(map[tailcfg.NodeID]tkatype.MarshaledSignature, len(initResp.NeedSignatures))
@@ -670,6 +699,13 @@ 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 {
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 {
b.mu.Lock()
@@ -702,16 +738,14 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
id1, id2 := b.tka.authority.StateIDs()
stateID := fmt.Sprintf("%d:%d", id1, id2)
cn := b.currentNode()
newPrefs := b.pm.CurrentPrefs().AsStruct().Clone() // .Persist should always be initialized here.
newPrefs.Persist.DisallowedTKAStateIDs = append(newPrefs.Persist.DisallowedTKAStateIDs, stateID)
if err := b.pm.SetPrefs(newPrefs.View(), ipn.NetworkProfile{
MagicDNSName: b.netMap.MagicDNSSuffix(),
DomainName: b.netMap.DomainName(),
}); err != nil {
if err := b.pm.SetPrefs(newPrefs.View(), cn.NetworkProfile()); err != nil {
return fmt.Errorf("saving prefs: %w", err)
}
if err := os.RemoveAll(b.chonkPathLocked()); err != nil {
if err := b.tka.storage.RemoveAll(); err != nil {
return fmt.Errorf("deleting TKA state: %w", err)
}
b.tka = nil
@@ -897,7 +931,7 @@ func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpd
if err == os.ErrNotExist {
break
}
return out, fmt.Errorf("reading AUM: %w", err)
return out, fmt.Errorf("reading AUM (%v): %w", cursor, err)
}
update := ipnstate.NetworkLockUpdate{
@@ -1247,27 +1281,10 @@ func (b *LocalBackend) tkaFetchBootstrap(ourNodeKey key.NodePublic, head tka.AUM
return a, nil
}
func fromSyncOffer(offer tka.SyncOffer) (head string, ancestors []string, err error) {
headBytes, err := offer.Head.MarshalText()
if err != nil {
return "", nil, fmt.Errorf("head.MarshalText: %v", err)
}
ancestors = make([]string, len(offer.Ancestors))
for i, ancestor := range offer.Ancestors {
hash, err := ancestor.MarshalText()
if err != nil {
return "", nil, fmt.Errorf("ancestor[%d].MarshalText: %v", i, err)
}
ancestors[i] = string(hash)
}
return string(headBytes), ancestors, nil
}
// tkaDoSyncOffer sends a /machine/tka/sync/offer RPC to the control plane
// over noise. This is the first of two RPCs implementing tka synchronization.
func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncOffer) (*tailcfg.TKASyncOfferResponse, error) {
head, ancestors, err := fromSyncOffer(offer)
head, ancestors, err := tka.FromSyncOffer(offer)
if err != nil {
return nil, fmt.Errorf("encoding offer: %v", err)
}

872
vendor/tailscale.com/ipn/ipnlocal/node_backend.go generated vendored Normal file
View File

@@ -0,0 +1,872 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"cmp"
"context"
"net/netip"
"slices"
"sync"
"sync/atomic"
"go4.org/netipx"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/net/dns"
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/magicsock"
)
// nodeBackend is node-specific [LocalBackend] state. It is usually the current node.
//
// Its exported methods are safe for concurrent use, but the struct is not a snapshot of state at a given moment;
// its state can change between calls. For example, asking for the same value (e.g., netmap or prefs) twice
// may return different results. Returned values are immutable and safe for concurrent use.
//
// If both the [LocalBackend]'s internal mutex and the [nodeBackend] mutex must be held at the same time,
// the [LocalBackend] mutex must be acquired first. See the comment on the [LocalBackend] field for more details.
//
// Two pointers to different [nodeBackend] instances represent different local nodes.
// However, there's currently a bug where a new [nodeBackend] might not be created
// during an implicit node switch (see tailscale/corp#28014).
//
// In the future, we might want to include at least the following in this struct (in addition to the current fields).
// However, not everything should be exported or otherwise made available to the outside world (e.g. [ipnext] extensions,
// peer API handlers, etc.).
// - [ipn.State]: when the LocalBackend switches to a different [nodeBackend], it can update the state of the old one.
// - [ipn.LoginProfileView] and [ipn.Prefs]: we should update them when the [profileManager] reports changes to them.
// In the future, [profileManager] (and the corresponding methods of the [LocalBackend]) can be made optional,
// and something else could be used to set them once or update them as needed.
// - [tailcfg.HostinfoView]: it includes certain fields that are tied to the current profile/node/prefs. We should also
// update to build it once instead of mutating it in twelvety different places.
// - [filter.Filter] (normal and jailed, along with the filterHash): the nodeBackend could have a method to (re-)build
// the filter for the current netmap/prefs (see [LocalBackend.updateFilterLocked]), and it needs to track the current
// filters and their hash.
// - Fields related to a requested or required (re-)auth: authURL, authURLTime, authActor, keyExpired, etc.
// - [controlclient.Client]/[*controlclient.Auto]: the current control client. It is ties to a node identity.
// - [tkaState]: it is tied to the current profile / node.
// - Fields related to scheduled node expiration: nmExpiryTimer, numClientStatusCalls, [expiryManager].
//
// It should not include any fields used by specific features that don't belong in [LocalBackend].
// Even if they're tied to the local node, instead of moving them here, we should extract the entire feature
// into a separate package and have it install proper hooks.
type nodeBackend struct {
logf logger.Logf
ctx context.Context // canceled by [nodeBackend.shutdown]
ctxCancel context.CancelCauseFunc // cancels ctx
// filterAtomic is a stateful packet filter. Immutable once created, but can be
// replaced with a new one.
filterAtomic atomic.Pointer[filter.Filter]
// initialized once and immutable
eventClient *eventbus.Client
filterPub *eventbus.Publisher[magicsock.FilterUpdate]
nodeViewsPub *eventbus.Publisher[magicsock.NodeViewsUpdate]
nodeMutsPub *eventbus.Publisher[magicsock.NodeMutationsUpdate]
derpMapViewPub *eventbus.Publisher[tailcfg.DERPMapView]
// TODO(nickkhyl): maybe use sync.RWMutex?
mu syncs.Mutex // protects the following fields
shutdownOnce sync.Once // guards calling [nodeBackend.shutdown]
readyCh chan struct{} // closed by [nodeBackend.ready]; nil after shutdown
// NetMap is the most recently set full netmap from the controlclient.
// It can't be mutated in place once set. Because it can't be mutated in place,
// delta updates from the control server don't apply to it. Instead, use
// the peers map to get up-to-date information on the state of peers.
// In general, avoid using the netMap.Peers slice. We'd like it to go away
// as of 2023-09-17.
// TODO(nickkhyl): make it an atomic pointer to avoid the need for a mutex?
netMap *netmap.NetworkMap
// peers is the set of current peers and their current values after applying
// delta node mutations as they come in (with mu held). The map values can be
// given out to callers, but the map itself can be mutated in place (with mu held)
// and must not escape the [nodeBackend].
peers map[tailcfg.NodeID]tailcfg.NodeView
// nodeByAddr maps nodes' own addresses (excluding subnet routes) to node IDs.
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
nodeByAddr map[netip.Addr]tailcfg.NodeID
}
func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *nodeBackend {
ctx, ctxCancel := context.WithCancelCause(ctx)
nb := &nodeBackend{
logf: logf,
ctx: ctx,
ctxCancel: ctxCancel,
eventClient: bus.Client("ipnlocal.nodeBackend"),
readyCh: make(chan struct{}),
}
// Default filter blocks everything and logs nothing.
noneFilter := filter.NewAllowNone(logger.Discard, &netipx.IPSet{})
nb.filterAtomic.Store(noneFilter)
nb.filterPub = eventbus.Publish[magicsock.FilterUpdate](nb.eventClient)
nb.nodeViewsPub = eventbus.Publish[magicsock.NodeViewsUpdate](nb.eventClient)
nb.nodeMutsPub = eventbus.Publish[magicsock.NodeMutationsUpdate](nb.eventClient)
nb.derpMapViewPub = eventbus.Publish[tailcfg.DERPMapView](nb.eventClient)
nb.filterPub.Publish(magicsock.FilterUpdate{Filter: nb.filterAtomic.Load()})
return nb
}
// Context returns a context that is canceled when the [nodeBackend] shuts down,
// either because [LocalBackend] is switching to a different [nodeBackend]
// or is shutting down itself.
func (nb *nodeBackend) Context() context.Context {
return nb.ctx
}
func (nb *nodeBackend) Self() tailcfg.NodeView {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return tailcfg.NodeView{}
}
return nb.netMap.SelfNode
}
func (nb *nodeBackend) SelfUserID() tailcfg.UserID {
self := nb.Self()
if !self.Valid() {
return 0
}
return self.User()
}
// SelfHasCap reports whether the specified capability was granted to the self node in the most recent netmap.
func (nb *nodeBackend) SelfHasCap(wantCap tailcfg.NodeCapability) bool {
return nb.SelfHasCapOr(wantCap, false)
}
// SelfHasCapOr is like [nodeBackend.SelfHasCap], but returns the specified default value
// if the netmap is not available yet.
func (nb *nodeBackend) SelfHasCapOr(wantCap tailcfg.NodeCapability, def bool) bool {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return def
}
return nb.netMap.AllCaps.Contains(wantCap)
}
func (nb *nodeBackend) NetworkProfile() ipn.NetworkProfile {
nb.mu.Lock()
defer nb.mu.Unlock()
return ipn.NetworkProfile{
// These are ok to call with nil netMap.
MagicDNSName: nb.netMap.MagicDNSSuffix(),
DomainName: nb.netMap.DomainName(),
DisplayName: nb.netMap.TailnetDisplayName(),
}
}
// TODO(nickkhyl): update it to return a [tailcfg.DERPMapView]?
func (nb *nodeBackend) DERPMap() *tailcfg.DERPMap {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return nil
}
return nb.netMap.DERPMap
}
func (nb *nodeBackend) NodeByAddr(ip netip.Addr) (_ tailcfg.NodeID, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
nid, ok := nb.nodeByAddr[ip]
return nid, ok
}
func (nb *nodeBackend) NodeByKey(k key.NodePublic) (_ tailcfg.NodeID, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return 0, false
}
if self := nb.netMap.SelfNode; self.Valid() && self.Key() == k {
return self.ID(), true
}
// TODO(bradfitz,nickkhyl): add nodeByKey like nodeByAddr instead of walking peers.
for _, n := range nb.peers {
if n.Key() == k {
return n.ID(), true
}
}
return 0, false
}
func (nb *nodeBackend) NodeByID(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap != nil {
if self := nb.netMap.SelfNode; self.Valid() && self.ID() == id {
return self, true
}
}
n, ok := nb.peers[id]
return n, ok
}
func (nb *nodeBackend) PeerByStableID(id tailcfg.StableNodeID) (_ tailcfg.NodeView, ok bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
for _, n := range nb.peers {
if n.StableID() == id {
return n, true
}
}
return tailcfg.NodeView{}, false
}
func (nb *nodeBackend) UserByID(id tailcfg.UserID) (_ tailcfg.UserProfileView, ok bool) {
nb.mu.Lock()
nm := nb.netMap
nb.mu.Unlock()
if nm == nil {
return tailcfg.UserProfileView{}, false
}
u, ok := nm.UserProfiles[id]
return u, ok
}
// Peers returns all the current peers in an undefined order.
func (nb *nodeBackend) Peers() []tailcfg.NodeView {
nb.mu.Lock()
defer nb.mu.Unlock()
return slicesx.MapValues(nb.peers)
}
func (nb *nodeBackend) PeersForTest() []tailcfg.NodeView {
nb.mu.Lock()
defer nb.mu.Unlock()
ret := slicesx.MapValues(nb.peers)
slices.SortFunc(ret, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
})
return ret
}
func (nb *nodeBackend) CollectServices() bool {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.netMap != nil && nb.netMap.CollectServices
}
// AppendMatchingPeers returns base with all peers that match pred appended.
//
// It acquires b.mu to read the netmap but releases it before calling pred.
func (nb *nodeBackend) AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView {
var peers []tailcfg.NodeView
nb.mu.Lock()
if nb.netMap != nil {
// All fields on b.netMap are immutable, so this is
// safe to copy and use outside the lock.
peers = nb.netMap.Peers
}
nb.mu.Unlock()
ret := base
for _, peer := range peers {
// The peers in b.netMap don't contain updates made via
// UpdateNetmapDelta. So only use PeerView in b.netMap for its NodeID,
// and then look up the latest copy in b.peers which is updated in
// response to UpdateNetmapDelta edits.
nb.mu.Lock()
peer, ok := nb.peers[peer.ID()]
nb.mu.Unlock()
if ok && pred(peer) {
ret = append(ret, peer)
}
}
return ret
}
// PeerCaps returns the capabilities that remote src IP has to
// ths current node.
func (nb *nodeBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.peerCapsLocked(src)
}
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
if nb.netMap == nil {
return nil
}
filt := nb.filterAtomic.Load()
if filt == nil {
return nil
}
addrs := nb.netMap.GetAddresses()
for i := range addrs.Len() {
a := addrs.At(i)
if !a.IsSingleIP() {
continue
}
dst := a.Addr()
if dst.BitLen() == src.BitLen() { // match on family
return filt.CapsWithValues(src, dst)
}
}
return nil
}
// PeerHasCap reports whether the peer contains the given capability string,
// with any value(s).
func (nb *nodeBackend) PeerHasCap(peer tailcfg.NodeView, wantCap tailcfg.PeerCapability) bool {
if !peer.Valid() {
return false
}
nb.mu.Lock()
defer nb.mu.Unlock()
for _, ap := range peer.Addresses().All() {
if nb.peerHasCapLocked(ap.Addr(), wantCap) {
return true
}
}
return false
}
func (nb *nodeBackend) peerHasCapLocked(addr netip.Addr, wantCap tailcfg.PeerCapability) bool {
return nb.peerCapsLocked(addr).HasCapability(wantCap)
}
func (nb *nodeBackend) PeerHasPeerAPI(p tailcfg.NodeView) bool {
return nb.PeerAPIBase(p) != ""
}
// PeerAPIBase returns the "http://ip:port" URL base to reach peer's PeerAPI,
// or the empty string if the peer is invalid or doesn't support PeerAPI.
func (nb *nodeBackend) PeerAPIBase(p tailcfg.NodeView) string {
nb.mu.Lock()
nm := nb.netMap
nb.mu.Unlock()
return peerAPIBase(nm, p)
}
// PeerIsReachable reports whether the current node can reach p. If the ctx is
// done, this function may return a result based on stale reachability data.
func (nb *nodeBackend) PeerIsReachable(ctx context.Context, p tailcfg.NodeView) bool {
if !nb.SelfHasCap(tailcfg.NodeAttrClientSideReachability) {
// Legacy behavior is to always trust the control plane, which
// isnt always correct because the peer could be slow to check
// in so that control marks it as offline.
// See tailscale/corp#32686.
return p.Online().Get()
}
nb.mu.Lock()
nm := nb.netMap
nb.mu.Unlock()
if self := nm.SelfNode; self.Valid() && self.ID() == p.ID() {
// This node can always reach itself.
return true
}
return nb.peerIsReachable(ctx, p)
}
func (nb *nodeBackend) peerIsReachable(ctx context.Context, p tailcfg.NodeView) bool {
// TODO(sfllaw): The following does not actually test for client-side
// reachability. This would require a mechanism that tracks whether the
// current node can actually reach this peer, either because they are
// already communicating or because they can ping each other.
//
// Instead, it makes the client ignore p.Online completely.
//
// See tailscale/corp#32686.
return true
}
func nodeIP(n tailcfg.NodeView, pred func(netip.Addr) bool) netip.Addr {
for _, pfx := range n.Addresses().All() {
if pfx.IsSingleIP() && pred(pfx.Addr()) {
return pfx.Addr()
}
}
return netip.Addr{}
}
func (nb *nodeBackend) NetMap() *netmap.NetworkMap {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.netMap
}
func (nb *nodeBackend) netMapWithPeers() *netmap.NetworkMap {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil {
return nil
}
nm := ptr.To(*nb.netMap) // shallow clone
nm.Peers = slicesx.MapValues(nb.peers)
slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
})
return nm
}
func (nb *nodeBackend) SetNetMap(nm *netmap.NetworkMap) {
nb.mu.Lock()
defer nb.mu.Unlock()
nb.netMap = nm
nb.updateNodeByAddrLocked()
nb.updatePeersLocked()
nv := magicsock.NodeViewsUpdate{}
if nm != nil {
nv.SelfNode = nm.SelfNode
nv.Peers = nm.Peers
nb.derpMapViewPub.Publish(nm.DERPMap.View())
} else {
nb.derpMapViewPub.Publish(tailcfg.DERPMapView{})
}
nb.nodeViewsPub.Publish(nv)
}
func (nb *nodeBackend) updateNodeByAddrLocked() {
nm := nb.netMap
if nm == nil {
nb.nodeByAddr = nil
return
}
// Update the nodeByAddr index.
if nb.nodeByAddr == nil {
nb.nodeByAddr = map[netip.Addr]tailcfg.NodeID{}
}
// First pass, mark everything unwanted.
for k := range nb.nodeByAddr {
nb.nodeByAddr[k] = 0
}
addNode := func(n tailcfg.NodeView) {
for _, ipp := range n.Addresses().All() {
if ipp.IsSingleIP() {
nb.nodeByAddr[ipp.Addr()] = n.ID()
}
}
}
if nm.SelfNode.Valid() {
addNode(nm.SelfNode)
}
for _, p := range nm.Peers {
addNode(p)
}
// Third pass, actually delete the unwanted items.
for k, v := range nb.nodeByAddr {
if v == 0 {
delete(nb.nodeByAddr, k)
}
}
}
func (nb *nodeBackend) updatePeersLocked() {
nm := nb.netMap
if nm == nil {
nb.peers = nil
return
}
// First pass, mark everything unwanted.
for k := range nb.peers {
nb.peers[k] = tailcfg.NodeView{}
}
// Second pass, add everything wanted.
for _, p := range nm.Peers {
mak.Set(&nb.peers, p.ID(), p)
}
// Third pass, remove deleted things.
for k, v := range nb.peers {
if !v.Valid() {
delete(nb.peers, k)
}
}
}
func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.netMap == nil || len(nb.peers) == 0 {
return false
}
// Locally cloned mutable nodes, to avoid calling AsStruct (clone)
// multiple times on a node if it's mutated multiple times in this
// call (e.g. its endpoints + online status both change)
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
update := magicsock.NodeMutationsUpdate{
Mutations: make([]netmap.NodeMutation, 0, len(muts)),
}
for _, m := range muts {
n, ok := mutableNodes[m.NodeIDBeingMutated()]
if !ok {
nv, ok := nb.peers[m.NodeIDBeingMutated()]
if !ok {
// TODO(bradfitz): unexpected metric?
return false
}
n = nv.AsStruct()
mak.Set(&mutableNodes, nv.ID(), n)
update.Mutations = append(update.Mutations, m)
}
m.Apply(n)
}
for nid, n := range mutableNodes {
nb.peers[nid] = n.View()
}
nb.nodeMutsPub.Publish(update)
return true
}
// unlockedNodesPermitted reports whether any peer with theUnsignedPeerAPIOnly bool set true has any of its allowed IPs
// in the specified packet filter.
//
// TODO(nickkhyl): It is here temporarily until we can move the whole [LocalBackend.updateFilterLocked] here,
// but change it so it builds and returns a filter for the current netmap/prefs instead of re-configuring the engine filter.
// Something like (*nodeBackend).RebuildFilters() (filter, jailedFilter *filter.Filter, changed bool) perhaps?
func (nb *nodeBackend) unlockedNodesPermitted(packetFilter []filter.Match) bool {
nb.mu.Lock()
defer nb.mu.Unlock()
return packetFilterPermitsUnlockedNodes(nb.peers, packetFilter)
}
func (nb *nodeBackend) filter() *filter.Filter {
return nb.filterAtomic.Load()
}
func (nb *nodeBackend) setFilter(f *filter.Filter) {
nb.filterAtomic.Store(f)
nb.filterPub.Publish(magicsock.FilterUpdate{Filter: f})
}
func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, versionOS string) *dns.Config {
nb.mu.Lock()
defer nb.mu.Unlock()
return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, versionOS)
}
func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
if !buildfeatures.HasUseExitNode {
return "", false
}
nb.mu.Lock()
defer nb.mu.Unlock()
return exitNodeCanProxyDNS(nb.netMap, nb.peers, exitNodeID)
}
// ready signals that [LocalBackend] has completed the switch to this [nodeBackend]
// and any pending calls to [nodeBackend.Wait] must be unblocked.
func (nb *nodeBackend) ready() {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.readyCh != nil {
close(nb.readyCh)
}
}
// Wait blocks until [LocalBackend] completes the switch to this [nodeBackend]
// and calls [nodeBackend.ready]. It returns an error if the provided context
// is canceled or if the [nodeBackend] shuts down or is already shut down.
//
// It must not be called with the [LocalBackend]'s internal mutex held as [LocalBackend]
// may need to acquire it to complete the switch.
//
// TODO(nickkhyl): Relax this restriction once [LocalBackend]'s state machine
// runs in its own goroutine, or if we decide that waiting for the state machine
// restart to finish isn't necessary for [LocalBackend] to consider the switch complete.
// We mostly need this because of [LocalBackend.Start] acquiring b.mu and the fact that
// methods like [LocalBackend.SwitchProfile] must report any errors returned by it.
// Perhaps we could report those errors asynchronously as [health.Warnable]s?
func (nb *nodeBackend) Wait(ctx context.Context) error {
nb.mu.Lock()
readyCh := nb.readyCh
nb.mu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-nb.ctx.Done():
return context.Cause(nb.ctx)
case <-readyCh:
return nil
}
}
// shutdown shuts down the [nodeBackend] and cancels its context
// with the provided cause.
func (nb *nodeBackend) shutdown(cause error) {
nb.shutdownOnce.Do(func() {
nb.doShutdown(cause)
})
}
func (nb *nodeBackend) doShutdown(cause error) {
nb.mu.Lock()
defer nb.mu.Unlock()
nb.ctxCancel(cause)
nb.readyCh = nil
nb.eventClient.Close()
}
// useWithExitNodeResolvers filters out resolvers so the ones that remain
// are all the ones marked for use with exit nodes.
func useWithExitNodeResolvers(resolvers []*dnstype.Resolver) []*dnstype.Resolver {
var filtered []*dnstype.Resolver
for _, res := range resolvers {
if res.UseWithExitNode {
filtered = append(filtered, res)
}
}
return filtered
}
// useWithExitNodeRoutes filters out routes so the ones that remain
// are either zero-length resolver lists, or lists containing only
// resolvers marked for use with exit nodes.
func useWithExitNodeRoutes(routes map[string][]*dnstype.Resolver) map[string][]*dnstype.Resolver {
var filtered map[string][]*dnstype.Resolver
for suffix, resolvers := range routes {
// Suffixes with no resolvers represent a valid configuration,
// and should persist regardless of exit node considerations.
if len(resolvers) == 0 {
mak.Set(&filtered, suffix, make([]*dnstype.Resolver, 0))
continue
}
// In exit node contexts, we filter out resolvers not configured for use with
// exit nodes. If there are no such configured resolvers, there should not be an entry for that suffix.
filteredResolvers := useWithExitNodeResolvers(resolvers)
if len(filteredResolvers) > 0 {
mak.Set(&filtered, suffix, filteredResolvers)
}
}
return filtered
}
// dnsConfigForNetmap returns a *dns.Config for the given netmap,
// prefs, client OS version, and cloud hosting environment.
//
// The versionOS is a Tailscale-style version ("iOS", "macOS") and not
// a runtime.GOOS.
func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string) *dns.Config {
if nm == nil {
return nil
}
if !buildfeatures.HasDNS {
return &dns.Config{}
}
// If the current node's key is expired, then we don't program any DNS
// configuration into the operating system. This ensures that if the
// DNS configuration specifies a DNS server that is only reachable over
// Tailscale, we don't break connectivity for the user.
//
// TODO(andrew-d): this also stops returning anything from quad-100; we
// could do the same thing as having "CorpDNS: false" and keep that but
// not program the OS?
if selfExpired {
return &dns.Config{}
}
dcfg := &dns.Config{
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
Hosts: map[dnsname.FQDN][]netip.Addr{},
}
// selfV6Only is whether we only have IPv6 addresses ourselves.
selfV6Only := nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs6) &&
!nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs4)
dcfg.OnlyIPv6 = selfV6Only
wantAAAA := nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA)
// Populate MagicDNS records. We do this unconditionally so that
// quad-100 can always respond to MagicDNS queries, even if the OS
// isn't configured to make MagicDNS resolution truly
// magic. Details in
// https://github.com/tailscale/tailscale/issues/1886.
set := func(name string, addrs views.Slice[netip.Prefix]) {
if addrs.Len() == 0 || name == "" {
return
}
fqdn, err := dnsname.ToFQDN(name)
if err != nil {
return // TODO: propagate error?
}
var have4 bool
for _, addr := range addrs.All() {
if addr.Addr().Is4() {
have4 = true
break
}
}
var ips []netip.Addr
for _, addr := range addrs.All() {
if selfV6Only {
if addr.Addr().Is6() {
ips = append(ips, addr.Addr())
}
continue
}
// If this node has an IPv4 address, then
// remove peers' IPv6 addresses for now, as we
// don't guarantee that the peer node actually
// can speak IPv6 correctly.
//
// https://github.com/tailscale/tailscale/issues/1152
// tracks adding the right capability reporting to
// enable AAAA in MagicDNS.
if addr.Addr().Is6() && have4 && !wantAAAA {
continue
}
ips = append(ips, addr.Addr())
}
dcfg.Hosts[fqdn] = ips
}
set(nm.SelfName(), nm.GetAddresses())
for _, peer := range peers {
set(peer.Name(), peer.Addresses())
}
for _, rec := range nm.DNS.ExtraRecords {
switch rec.Type {
case "", "A", "AAAA":
// Treat these all the same for now: infer from the value
default:
// TODO: more
continue
}
ip, err := netip.ParseAddr(rec.Value)
if err != nil {
// Ignore.
continue
}
fqdn, err := dnsname.ToFQDN(rec.Name)
if err != nil {
continue
}
dcfg.Hosts[fqdn] = append(dcfg.Hosts[fqdn], ip)
}
if !prefs.CorpDNS() {
return dcfg
}
for _, dom := range nm.DNS.Domains {
fqdn, err := dnsname.ToFQDN(dom)
if err != nil {
logf("[unexpected] non-FQDN search domain %q", dom)
}
dcfg.SearchDomains = append(dcfg.SearchDomains, fqdn)
}
if nm.DNS.Proxied { // actually means "enable MagicDNS"
for _, dom := range magicDNSRootDomains(nm) {
dcfg.Routes[dom] = nil // resolve internally with dcfg.Hosts
}
}
addDefault := func(resolvers []*dnstype.Resolver) {
dcfg.DefaultResolvers = append(dcfg.DefaultResolvers, resolvers...)
}
addSplitDNSRoutes := func(routes map[string][]*dnstype.Resolver) {
for suffix, resolvers := range routes {
fqdn, err := dnsname.ToFQDN(suffix)
if err != nil {
logf("[unexpected] non-FQDN route suffix %q", suffix)
}
// Create map entry even if len(resolvers) == 0; Issue 2706.
// This lets the control plane send ExtraRecords for which we
// can authoritatively answer "name not exists" for when the
// control plane also sends this explicit but empty route
// making it as something we handle.
dcfg.Routes[fqdn] = slices.Clone(resolvers)
}
}
// If we're using an exit node and that exit node is new enough (1.19.x+)
// to run a DoH DNS proxy, then send all our DNS traffic through it,
// unless we find resolvers with UseWithExitNode set, in which case we use that.
if buildfeatures.HasUseExitNode {
if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok {
filtered := useWithExitNodeResolvers(nm.DNS.Resolvers)
if len(filtered) > 0 {
addDefault(filtered)
} else {
// If no default global resolvers with the override
// are configured, configure the exit node's resolver.
addDefault([]*dnstype.Resolver{{Addr: dohURL}})
}
addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes))
return dcfg
}
}
// If the user has set default resolvers ("override local DNS"), prefer to
// use those resolvers as the default, otherwise if there are WireGuard exit
// node resolvers, use those as the default.
if len(nm.DNS.Resolvers) > 0 {
addDefault(nm.DNS.Resolvers)
} else if buildfeatures.HasUseExitNode {
if resolvers, ok := wireguardExitNodeDNSResolvers(nm, peers, prefs.ExitNodeID()); ok {
addDefault(resolvers)
}
}
// Add split DNS routes, with no regard to exit node configuration.
addSplitDNSRoutes(nm.DNS.Routes)
// Set FallbackResolvers as the default resolvers in the
// scenarios that can't handle a purely split-DNS config. See
// https://github.com/tailscale/tailscale/issues/1743 for
// details.
switch {
case len(dcfg.DefaultResolvers) != 0:
// Default resolvers already set.
case !prefs.ExitNodeID().IsZero():
// When using an exit node, we send all DNS traffic to the exit node, so
// we don't need a fallback resolver.
//
// However, if the exit node is too old to run a DoH DNS proxy, then we
// need to use a fallback resolver as it's very likely the LAN resolvers
// will become unreachable.
//
// This is especially important on Apple OSes, where
// adding the default route to the tunnel interface makes
// it "primary", and we MUST provide VPN-sourced DNS
// settings or we break all DNS resolution.
//
// https://github.com/tailscale/tailscale/issues/1713
addDefault(nm.DNS.FallbackResolvers)
case len(dcfg.Routes) == 0:
// No settings requiring split DNS, no problem.
}
return dcfg
}

View File

@@ -15,9 +15,7 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
@@ -27,33 +25,25 @@ import (
"golang.org/x/net/dns/dnsmessage"
"golang.org/x/net/http/httpguts"
"tailscale.com/drive"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/net/netaddr"
"tailscale.com/net/netmon"
"tailscale.com/net/netutil"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/taildrop"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/httphdr"
"tailscale.com/util/httpm"
"tailscale.com/wgengine/filter"
)
const (
taildrivePrefix = "/v0/drive"
)
var initListenConfig func(*net.ListenConfig, netip.Addr, *netmon.State, string) error
// addH2C is non-nil on platforms where we want to add H2C
// ("cleartext" HTTP/2) support to the peerAPI.
var addH2C func(*http.Server)
// 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.
var initListenConfig func(config *net.ListenConfig, addr netip.Addr, tunIfIndex int) error
// peerDNSQueryHandler is implemented by tsdns.Resolver.
type peerDNSQueryHandler interface {
@@ -63,11 +53,9 @@ type peerDNSQueryHandler interface {
type peerAPIServer struct {
b *LocalBackend
resolver peerDNSQueryHandler
taildrop *taildrop.Manager
}
func (s *peerAPIServer) listen(ip netip.Addr, ifState *netmon.State) (ln net.Listener, err error) {
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
@@ -83,7 +71,14 @@ func (s *peerAPIServer) listen(ip netip.Addr, ifState *netmon.State) (ln net.Lis
// On iOS/macOS, this sets the lc.Control hook to
// setsockopt the interface index to bind to, to get
// out of the network sandbox.
if err := initListenConfig(&lc, ip, ifState, s.b.dialer.TUNName()); err != nil {
// 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)
}
if err := initListenConfig(&lc, ip, tunIfIndex); err != nil {
return nil, err
}
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
@@ -146,6 +141,9 @@ type peerAPIListener struct {
}
func (pln *peerAPIListener) Close() error {
if !buildfeatures.HasPeerAPIServer {
return nil
}
if pln.ln != nil {
return pln.ln.Close()
}
@@ -153,6 +151,9 @@ func (pln *peerAPIListener) Close() error {
}
func (pln *peerAPIListener) serve() {
if !buildfeatures.HasPeerAPIServer {
return
}
if pln.ln == nil {
return
}
@@ -206,11 +207,11 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) {
peerUser: peerUser,
}
httpServer := &http.Server{
Handler: h,
}
if addH2C != nil {
addH2C(httpServer)
Handler: h,
Protocols: new(http.Protocols),
}
httpServer.Protocols.SetHTTP1(true)
httpServer.Protocols.SetUnencryptedHTTP2(true) // over WireGuard; "unencrypted" means no TLS
go httpServer.Serve(netutil.NewOneConnListener(c, nil))
}
@@ -229,9 +230,12 @@ type peerAPIHandler struct {
type PeerAPIHandler interface {
Peer() tailcfg.NodeView
PeerCaps() tailcfg.PeerCapMap
CanDebug() bool // can remote node can debug this node (internal state, etc)
Self() tailcfg.NodeView
LocalBackend() *LocalBackend
IsSelfUntagged() bool // whether the peer is untagged and the same as this user
RemoteAddr() netip.AddrPort
Logf(format string, a ...any)
}
func (h *peerAPIHandler) IsSelfUntagged() bool {
@@ -239,12 +243,20 @@ func (h *peerAPIHandler) IsSelfUntagged() bool {
}
func (h *peerAPIHandler) Peer() tailcfg.NodeView { return h.peerNode }
func (h *peerAPIHandler) Self() tailcfg.NodeView { return h.selfNode }
func (h *peerAPIHandler) RemoteAddr() netip.AddrPort { return h.remoteAddr }
func (h *peerAPIHandler) LocalBackend() *LocalBackend { return h.ps.b }
func (h *peerAPIHandler) Logf(format string, a ...any) {
h.logf(format, a...)
}
func (h *peerAPIHandler) logf(format string, a ...any) {
h.ps.b.logf("peerapi: "+format, a...)
}
func (h *peerAPIHandler) logfv1(format string, a ...any) {
h.ps.b.logf("[v1] peerapi: "+format, a...)
}
// isAddressValid reports whether addr is a valid destination address for this
// node originating from the peer.
func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool {
@@ -323,15 +335,31 @@ func peerAPIRequestShouldGetSecurityHeaders(r *http.Request) bool {
//
// It panics if the path is already registered.
func RegisterPeerAPIHandler(path string, f func(PeerAPIHandler, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasPeerAPIServer {
return
}
if _, ok := peerAPIHandlers[path]; ok {
panic(fmt.Sprintf("duplicate PeerAPI handler %q", path))
}
peerAPIHandlers[path] = f
if strings.HasSuffix(path, "/") {
peerAPIHandlerPrefixes[path] = f
}
}
var peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path
var (
peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path
// peerAPIHandlerPrefixes are the subset of peerAPIHandlers where
// the map key ends with a slash, indicating a prefix match.
peerAPIHandlerPrefixes = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){}
)
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasPeerAPIServer {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if err := h.validatePeerAPIRequest(r); err != nil {
metricInvalidRequests.Add(1)
h.logf("invalid request from %v: %v", h.remoteAddr, err)
@@ -343,56 +371,50 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
}
if strings.HasPrefix(r.URL.Path, "/v0/put/") {
if r.Method == "PUT" {
metricPutCalls.Add(1)
for pfx, ph := range peerAPIHandlerPrefixes {
if strings.HasPrefix(r.URL.Path, pfx) {
ph(h, w, r)
return
}
h.handlePeerPut(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/dns-query") {
if buildfeatures.HasDNS && strings.HasPrefix(r.URL.Path, "/dns-query") {
metricDNSCalls.Add(1)
h.handleDNSQuery(w, r)
return
}
if strings.HasPrefix(r.URL.Path, taildrivePrefix) {
h.handleServeDrive(w, r)
return
}
switch r.URL.Path {
case "/v0/goroutines":
h.handleServeGoroutines(w, r)
return
case "/v0/env":
h.handleServeEnv(w, r)
return
case "/v0/metrics":
h.handleServeMetrics(w, r)
return
case "/v0/magicsock":
h.handleServeMagicsock(w, r)
return
case "/v0/dnsfwd":
h.handleServeDNSFwd(w, r)
return
case "/v0/interfaces":
h.handleServeInterfaces(w, r)
return
case "/v0/doctor":
h.handleServeDoctor(w, r)
return
case "/v0/sockstats":
h.handleServeSockStats(w, r)
return
case "/v0/ingress":
metricIngressCalls.Add(1)
h.handleServeIngress(w, r)
return
if buildfeatures.HasDebug {
switch r.URL.Path {
case "/v0/goroutines":
h.handleServeGoroutines(w, r)
return
case "/v0/env":
h.handleServeEnv(w, r)
return
case "/v0/metrics":
h.handleServeMetrics(w, r)
return
case "/v0/magicsock":
h.handleServeMagicsock(w, r)
return
case "/v0/dnsfwd":
h.handleServeDNSFwd(w, r)
return
case "/v0/interfaces":
h.handleServeInterfaces(w, r)
return
case "/v0/sockstats":
h.handleServeSockStats(w, r)
return
}
}
if ph, ok := peerAPIHandlers[r.URL.Path]; ok {
ph(h, w, r)
return
}
if r.URL.Path != "/" {
http.Error(w, "unsupported peerapi path", http.StatusNotFound)
return
}
who := h.peerUser.DisplayName
fmt.Fprintf(w, `<html>
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -406,67 +428,6 @@ This is my Tailscale device. Your device is %v.
}
}
func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) {
// http.Errors only useful if hitting endpoint manually
// otherwise rely on log lines when debugging ingress connections
// as connection is hijacked for bidi and is encrypted tls
if !h.canIngress() {
h.logf("ingress: denied; no ingress cap from %v", h.remoteAddr)
http.Error(w, "denied; no ingress cap", http.StatusForbidden)
return
}
logAndError := func(code int, publicMsg string) {
h.logf("ingress: bad request from %v: %s", h.remoteAddr, publicMsg)
http.Error(w, publicMsg, http.StatusMethodNotAllowed)
}
bad := func(publicMsg string) {
logAndError(http.StatusBadRequest, publicMsg)
}
if r.Method != "POST" {
logAndError(http.StatusMethodNotAllowed, "only POST allowed")
return
}
srcAddrStr := r.Header.Get("Tailscale-Ingress-Src")
if srcAddrStr == "" {
bad("Tailscale-Ingress-Src header not set")
return
}
srcAddr, err := netip.ParseAddrPort(srcAddrStr)
if err != nil {
bad("Tailscale-Ingress-Src header invalid; want ip:port")
return
}
target := ipn.HostPort(r.Header.Get("Tailscale-Ingress-Target"))
if target == "" {
bad("Tailscale-Ingress-Target header not set")
return
}
if _, _, err := net.SplitHostPort(string(target)); err != nil {
bad("Tailscale-Ingress-Target header invalid; want host:port")
return
}
getConnOrReset := func() (net.Conn, bool) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
h.logf("ingress: failed hijacking conn")
http.Error(w, "failed hijacking conn", http.StatusInternalServerError)
return nil, false
}
io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n\r\n")
return &ipn.FunnelConn{
Conn: conn,
Src: srcAddr,
Target: target,
}, true
}
sendRST := func() {
http.Error(w, "denied", http.StatusForbidden)
}
h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConnOrReset, sendRST)
}
func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) {
if !h.canDebug() {
http.Error(w, "denied; no debug access", http.StatusForbidden)
@@ -514,24 +475,6 @@ func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Re
fmt.Fprintln(w, "</table>")
}
func (h *peerAPIHandler) handleServeDoctor(w http.ResponseWriter, r *http.Request) {
if !h.canDebug() {
http.Error(w, "denied; no debug access", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintln(w, "<h1>Doctor Output</h1>")
fmt.Fprintln(w, "<pre>")
h.ps.b.Doctor(r.Context(), func(format string, args ...any) {
line := fmt.Sprintf(format, args...)
fmt.Fprintln(w, html.EscapeString(line))
})
fmt.Fprintln(w, "</pre>")
}
func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Request) {
if !h.canDebug() {
http.Error(w, "denied; no debug access", http.StatusForbidden)
@@ -630,14 +573,7 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
fmt.Fprintln(w, "</pre>")
}
// canPutFile reports whether h can put a file ("Taildrop") to this node.
func (h *peerAPIHandler) canPutFile() bool {
if h.peerNode.UnsignedPeerAPIOnly() {
// Unsigned peers can't send files.
return false
}
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend)
}
func (h *peerAPIHandler) CanDebug() bool { return h.canDebug() }
// canDebug reports whether h can debug this node (goroutines, metrics,
// magicsock internal state, etc).
@@ -668,110 +604,6 @@ func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap {
return h.ps.b.PeerCaps(h.remoteAddr.Addr())
}
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
if !h.canPutFile() {
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden)
return
}
if !h.ps.b.hasCapFileSharing() {
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden)
return
}
rawPath := r.URL.EscapedPath()
prefix, ok := strings.CutPrefix(rawPath, "/v0/put/")
if !ok {
http.Error(w, "misconfigured internals", http.StatusForbidden)
return
}
baseName, err := url.PathUnescape(prefix)
if err != nil {
http.Error(w, taildrop.ErrInvalidFileName.Error(), http.StatusBadRequest)
return
}
enc := json.NewEncoder(w)
switch r.Method {
case "GET":
id := taildrop.ClientID(h.peerNode.StableID())
if prefix == "" {
// List all the partial files.
files, err := h.ps.taildrop.PartialFiles(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := enc.Encode(files); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
h.logf("json.Encoder.Encode error: %v", err)
return
}
} else {
// Stream all the block hashes for the specified file.
next, close, err := h.ps.taildrop.HashPartialFile(id, baseName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer close()
for {
switch cs, err := next(); {
case err == io.EOF:
return
case err != nil:
http.Error(w, err.Error(), http.StatusInternalServerError)
h.logf("HashPartialFile.next error: %v", err)
return
default:
if err := enc.Encode(cs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
h.logf("json.Encoder.Encode error: %v", err)
return
}
}
}
}
case "PUT":
t0 := h.ps.b.clock.Now()
id := taildrop.ClientID(h.peerNode.StableID())
var offset int64
if rangeHdr := r.Header.Get("Range"); rangeHdr != "" {
ranges, ok := httphdr.ParseRange(rangeHdr)
if !ok || len(ranges) != 1 || ranges[0].Length != 0 {
http.Error(w, "invalid Range header", http.StatusBadRequest)
return
}
offset = ranges[0].Start
}
n, err := h.ps.taildrop.PutFile(taildrop.ClientID(fmt.Sprint(id)), baseName, r.Body, offset, r.ContentLength)
switch err {
case nil:
d := h.ps.b.clock.Since(t0).Round(time.Second / 10)
h.logf("got put of %s in %v from %v/%v", approxSize(n), d, h.remoteAddr.Addr(), h.peerNode.ComputedName)
io.WriteString(w, "{}\n")
case taildrop.ErrNoTaildrop:
http.Error(w, err.Error(), http.StatusForbidden)
case taildrop.ErrInvalidFileName:
http.Error(w, err.Error(), http.StatusBadRequest)
case taildrop.ErrFileExists:
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
default:
http.Error(w, "expected method GET or PUT", http.StatusMethodNotAllowed)
}
}
func approxSize(n int64) string {
if n <= 1<<10 {
return "<=1KB"
}
if n <= 1<<20 {
return "<=1MB"
}
return fmt.Sprintf("~%dMB", n>>20)
}
func (h *peerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Request) {
if !h.canDebug() {
http.Error(w, "denied; no debug access", http.StatusForbidden)
@@ -826,6 +658,10 @@ func (h *peerAPIHandler) handleServeMetrics(w http.ResponseWriter, r *http.Reque
}
func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasDNS {
http.NotFound(w, r)
return
}
if !h.canDebug() {
http.Error(w, "denied; no debug access", http.StatusForbidden)
return
@@ -839,6 +675,9 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
}
func (h *peerAPIHandler) replyToDNSQueries() bool {
if !buildfeatures.HasDNS {
return false
}
if h.isSelf {
// If the peer is owned by the same user, just allow it
// without further checks.
@@ -868,7 +707,7 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
// but an app connector explicitly adds 0.0.0.0/32 (and the
// IPv6 equivalent) to make this work (see updateFilterLocked
// in LocalBackend).
f := b.filterAtomic.Load()
f := b.currentNode().filter()
if f == nil {
return false
}
@@ -890,7 +729,7 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
// handleDNSQuery implements a DoH server (RFC 8484) over the peerapi.
// It's not over HTTPS as the spec dictates, but rather HTTP-over-WireGuard.
func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request) {
if h.ps.resolver == nil {
if !buildfeatures.HasDNS || h.ps.resolver == nil {
http.Error(w, "DNS not wired up", http.StatusNotImplemented)
return
}
@@ -931,7 +770,7 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
// TODO(raggi): consider pushing the integration down into the resolver
// instead to avoid re-parsing the DNS response for improved performance in
// the future.
if h.ps.b.OfferingAppConnector() {
if buildfeatures.HasAppConnectors && h.ps.b.OfferingAppConnector() {
if err := h.ps.b.ObserveDNSResponse(res); err != nil {
h.logf("ObserveDNSResponse error: %v", err)
// This is not fatal, we probably just failed to parse the upstream
@@ -958,7 +797,7 @@ func dohQuery(r *http.Request) (dnsQuery []byte, publicErr string) {
case "GET":
q64 := r.FormValue("dns")
if q64 == "" {
return nil, "missing 'dns' parameter"
return nil, "missing dns parameter; try '?dns=' (DoH standard) or use '?q=<name>' for JSON debug mode"
}
if base64.RawURLEncoding.DecodedLen(len(q64)) > maxQueryLen {
return nil, "query too large"
@@ -1113,85 +952,46 @@ func (rbw *requestBodyWrapper) Read(b []byte) (int, error) {
return n, err
}
func (h *peerAPIHandler) handleServeDrive(w http.ResponseWriter, r *http.Request) {
if !h.ps.b.DriveSharingEnabled() {
h.logf("taildrive: not enabled")
http.Error(w, "taildrive not enabled", http.StatusNotFound)
return
// peerAPIURL returns an HTTP URL for the peer's peerapi service,
// without a trailing slash.
//
// If ip or port is the zero value then it returns the empty string.
func peerAPIURL(ip netip.Addr, port uint16) string {
if port == 0 || !ip.IsValid() {
return ""
}
capsMap := h.PeerCaps()
driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
if !ok {
h.logf("taildrive: not permitted")
http.Error(w, "taildrive not permitted", http.StatusForbidden)
return
}
rawPerms := make([][]byte, 0, len(driveCaps))
for _, cap := range driveCaps {
rawPerms = append(rawPerms, []byte(cap))
}
p, err := drive.ParsePermissions(rawPerms)
if err != nil {
h.logf("taildrive: error parsing permissions: %w", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fs, ok := h.ps.b.sys.DriveForRemote.GetOK()
if !ok {
h.logf("taildrive: not supported on platform")
http.Error(w, "taildrive not supported on platform", http.StatusNotFound)
return
}
wr := &httpResponseWrapper{
ResponseWriter: w,
}
bw := &requestBodyWrapper{
ReadCloser: r.Body,
}
r.Body = bw
if r.Method == httpm.PUT || r.Method == httpm.GET {
defer func() {
switch wr.statusCode {
case 304:
// 304s are particularly chatty so skip logging.
default:
contentType := "unknown"
if ct := wr.Header().Get("Content-Type"); ct != "" {
contentType = ct
}
h.logf("taildrive: share: %s from %s to %s: status-code=%d ext=%q content-type=%q tx=%.f rx=%.f", r.Method, h.peerNode.Key().ShortString(), h.selfNode.Key().ShortString(), wr.statusCode, parseDriveFileExtensionForLog(r.URL.Path), contentType, roundTraffic(wr.contentLength), roundTraffic(bw.bytesRead))
}
}()
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix)
fs.ServeHTTPWithPerms(p, wr, r)
return fmt.Sprintf("http://%v", netip.AddrPortFrom(ip, port))
}
// parseDriveFileExtensionForLog parses the file extension, if available.
// If a file extension is not present or parsable, the file extension is
// set to "unknown". If the file extension contains a double quote, it is
// replaced with "removed".
// All whitespace is removed from a parsed file extension.
// File extensions including the leading ., e.g. ".gif".
func parseDriveFileExtensionForLog(path string) string {
fileExt := "unknown"
if fe := filepath.Ext(path); fe != "" {
if strings.Contains(fe, "\"") {
// Do not log include file extensions with quotes within them.
return "removed"
}
// Remove white space from user defined inputs.
fileExt = strings.ReplaceAll(fe, " ", "")
// peerAPIBase returns the "http://ip:port" URL base to reach peer's peerAPI.
// It returns the empty string if the peer doesn't support the peerapi
// or there's no matching address family based on the netmap's own addresses.
func peerAPIBase(nm *netmap.NetworkMap, peer tailcfg.NodeView) string {
if nm == nil || !peer.Valid() || !peer.Hostinfo().Valid() {
return ""
}
return fileExt
var have4, have6 bool
addrs := nm.GetAddresses()
for _, a := range addrs.All() {
if !a.IsSingleIP() {
continue
}
switch {
case a.Addr().Is4():
have4 = true
case a.Addr().Is6():
have6 = true
}
}
p4, p6 := peerAPIPorts(peer)
switch {
case have4 && p4 != 0:
return peerAPIURL(nodeIP(peer, netip.Addr.Is4), p4)
case have6 && p6 != 0:
return peerAPIURL(nodeIP(peer, netip.Addr.Is6), p6)
}
return ""
}
// newFakePeerAPIListener creates a new net.Listener that acts like
@@ -1244,7 +1044,5 @@ var (
metricInvalidRequests = clientmetric.NewCounter("peerapi_invalid_requests")
// Non-debug PeerAPI endpoints.
metricPutCalls = clientmetric.NewCounter("peerapi_put")
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
)

110
vendor/tailscale.com/ipn/ipnlocal/peerapi_drive.go generated vendored Normal file
View File

@@ -0,0 +1,110 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package ipnlocal
import (
"net/http"
"path/filepath"
"strings"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
)
const (
taildrivePrefix = "/v0/drive"
)
func init() {
peerAPIHandlerPrefixes[taildrivePrefix] = handleServeDrive
}
func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
h := hi.(*peerAPIHandler)
h.logfv1("taildrive: got %s request from %s", r.Method, h.peerNode.Key().ShortString())
if !h.ps.b.DriveSharingEnabled() {
h.logf("taildrive: not enabled")
http.Error(w, "taildrive not enabled", http.StatusNotFound)
return
}
capsMap := h.PeerCaps()
driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
if !ok {
h.logf("taildrive: not permitted")
http.Error(w, "taildrive not permitted", http.StatusForbidden)
return
}
rawPerms := make([][]byte, 0, len(driveCaps))
for _, cap := range driveCaps {
rawPerms = append(rawPerms, []byte(cap))
}
p, err := drive.ParsePermissions(rawPerms)
if err != nil {
h.logf("taildrive: error parsing permissions: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fs, ok := h.ps.b.sys.DriveForRemote.GetOK()
if !ok {
h.logf("taildrive: not supported on platform")
http.Error(w, "taildrive not supported on platform", http.StatusNotFound)
return
}
wr := &httpResponseWrapper{
ResponseWriter: w,
}
bw := &requestBodyWrapper{
ReadCloser: r.Body,
}
r.Body = bw
defer func() {
switch wr.statusCode {
case 304:
// 304s are particularly chatty so skip logging.
default:
log := h.logf
if r.Method != httpm.PUT && r.Method != httpm.GET {
log = h.logfv1
}
contentType := "unknown"
if ct := wr.Header().Get("Content-Type"); ct != "" {
contentType = ct
}
log("taildrive: share: %s from %s to %s: status-code=%d ext=%q content-type=%q tx=%.f rx=%.f", r.Method, h.peerNode.Key().ShortString(), h.selfNode.Key().ShortString(), wr.statusCode, parseDriveFileExtensionForLog(r.URL.Path), contentType, roundTraffic(wr.contentLength), roundTraffic(bw.bytesRead))
}
}()
r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix)
fs.ServeHTTPWithPerms(p, wr, r)
}
// parseDriveFileExtensionForLog parses the file extension, if available.
// If a file extension is not present or parsable, the file extension is
// set to "unknown". If the file extension contains a double quote, it is
// replaced with "removed".
// All whitespace is removed from a parsed file extension.
// File extensions including the leading ., e.g. ".gif".
func parseDriveFileExtensionForLog(path string) string {
fileExt := "unknown"
if fe := filepath.Ext(path); fe != "" {
if strings.Contains(fe, "\"") {
// Do not log include file extensions with quotes within them.
return "removed"
}
// Remove white space from user defined inputs.
fileExt = strings.ReplaceAll(fe, " ", "")
}
return fileExt
}

View File

@@ -1,20 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !js
package ipnlocal
import (
"net/http"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func init() {
addH2C = func(s *http.Server) {
h2s := &http2.Server{}
s.Handler = h2c.NewHandler(s.Handler, h2s)
}
}

View File

@@ -6,11 +6,9 @@
package ipnlocal
import (
"fmt"
"net"
"net/netip"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
)
@@ -21,10 +19,6 @@ func init() {
// initListenConfigNetworkExtension configures nc for listening on IP
// through the iOS/macOS Network/System Extension (Packet Tunnel
// Provider) sandbox.
func initListenConfigNetworkExtension(nc *net.ListenConfig, ip netip.Addr, st *netmon.State, tunIfName string) error {
tunIf, ok := st.Interface[tunIfName]
if !ok {
return fmt.Errorf("no interface with name %q", tunIfName)
}
return netns.SetListenConfigInterfaceIndex(nc, tunIf.Index)
func initListenConfigNetworkExtension(nc *net.ListenConfig, ip netip.Addr, ifaceIndex int) error {
return netns.SetListenConfigInterfaceIndex(nc, ifaceIndex)
}

103
vendor/tailscale.com/ipn/ipnlocal/prefs_metrics.go generated vendored Normal file
View File

@@ -0,0 +1,103 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"errors"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
)
// Counter metrics for edit/change events
var (
// metricExitNodeEnabled is incremented when the user enables an exit node independent of the node's characteristics.
metricExitNodeEnabled = clientmetric.NewCounter("prefs_exit_node_enabled")
// metricExitNodeEnabledSuggested is incremented when the user enables the suggested exit node.
metricExitNodeEnabledSuggested = clientmetric.NewCounter("prefs_exit_node_enabled_suggested")
// metricExitNodeEnabledMullvad is incremented when the user enables a Mullvad exit node.
metricExitNodeEnabledMullvad = clientmetric.NewCounter("prefs_exit_node_enabled_mullvad")
// metricWantRunningEnabled is incremented when WantRunning transitions from false to true.
metricWantRunningEnabled = clientmetric.NewCounter("prefs_want_running_enabled")
// metricWantRunningDisabled is incremented when WantRunning transitions from true to false.
metricWantRunningDisabled = clientmetric.NewCounter("prefs_want_running_disabled")
)
type exitNodeProperty string
const (
exitNodeTypePreferred exitNodeProperty = "suggested" // The exit node is the last suggested exit node
exitNodeTypeMullvad exitNodeProperty = "mullvad" // The exit node is a Mullvad exit node
)
// prefsMetricsEditEvent encapsulates information needed to record metrics related
// to any changes to preferences.
type prefsMetricsEditEvent struct {
change *ipn.MaskedPrefs // the preference mask used to update the preferences
pNew ipn.PrefsView // new preferences (after ApplyUpdates)
pOld ipn.PrefsView // old preferences (before ApplyUpdates)
node *nodeBackend // the node the event is associated with
lastSuggestedExitNode tailcfg.StableNodeID // the last suggested exit node
}
// record records changes to preferences as clientmetrics.
func (e *prefsMetricsEditEvent) record() error {
if e.change == nil || e.node == nil {
return errors.New("prefsMetricsEditEvent: missing required fields")
}
// Record up/down events.
if e.change.WantRunningSet && (e.pNew.WantRunning() != e.pOld.WantRunning()) {
if e.pNew.WantRunning() {
metricWantRunningEnabled.Add(1)
} else {
metricWantRunningDisabled.Add(1)
}
}
// Record any changes to exit node settings.
if e.change.ExitNodeIDSet || e.change.ExitNodeIPSet {
if exitNodeTypes, ok := e.exitNodeType(e.pNew.ExitNodeID()); ok {
// We have switched to a valid exit node if ok is true.
metricExitNodeEnabled.Add(1)
// We may have some additional characteristics we should also record.
for _, t := range exitNodeTypes {
switch t {
case exitNodeTypePreferred:
metricExitNodeEnabledSuggested.Add(1)
case exitNodeTypeMullvad:
metricExitNodeEnabledMullvad.Add(1)
}
}
}
}
return nil
}
// exitNodeTypesLocked returns type of exit node for the given stable ID.
// An exit node may have multiple type (can be both mullvad and preferred
// simultaneously for example).
//
// This will return ok as true if the supplied stable ID resolves to a known peer,
// false otherwise. The caller is responsible for ensuring that the id belongs to
// an exit node.
func (e *prefsMetricsEditEvent) exitNodeType(id tailcfg.StableNodeID) (props []exitNodeProperty, isNode bool) {
if !buildfeatures.HasUseExitNode {
return nil, false
}
var peer tailcfg.NodeView
if peer, isNode = e.node.PeerByStableID(id); isNode {
if tailcfg.StableNodeID(id) == e.lastSuggestedExitNode {
props = append(props, exitNodeTypePreferred)
}
if peer.IsWireGuardOnly() {
props = append(props, exitNodeTypeMullvad)
}
}
return props, isNode
}

View File

@@ -5,25 +5,35 @@ package ipnlocal
import (
"cmp"
"crypto"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"runtime"
"slices"
"strings"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
"tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus"
"tailscale.com/util/testenv"
)
var debug = envknob.RegisterBool("TS_DEBUG_PROFILES")
// [profileManager] implements [ipnext.ProfileStore].
var _ ipnext.ProfileStore = (*profileManager)(nil)
// profileManager is a wrapper around an [ipn.StateStore] that manages
// multiple profiles and the current profile.
//
@@ -36,8 +46,33 @@ type profileManager struct {
currentUserID ipn.WindowsUserID
knownProfiles map[ipn.ProfileID]ipn.LoginProfileView // always non-nil
currentProfile ipn.LoginProfileView // always Valid.
prefs ipn.PrefsView // always Valid.
currentProfile ipn.LoginProfileView // always Valid (once [newProfileManager] returns).
prefs ipn.PrefsView // always Valid (once [newProfileManager] returns).
// StateChangeHook is an optional hook that is called when the current profile or prefs change,
// such as due to a profile switch or a change in the profile's preferences.
// It is typically set by the [LocalBackend] to invert the dependency between
// the [profileManager] and the [LocalBackend], so that instead of [LocalBackend]
// asking [profileManager] for the state, we can have [profileManager] call
// [LocalBackend] when the state changes. See also:
// https://github.com/tailscale/tailscale/pull/15791#discussion_r2060838160
StateChangeHook ipnext.ProfileStateChangeCallback
// extHost is the bridge between [profileManager] and the registered [ipnext.Extension]s.
// It may be nil in tests. A nil pointer is a valid, no-op host.
extHost *ExtensionHost
// Override for key.NewEmptyHardwareAttestationKey used for testing.
newEmptyHardwareAttestationKey func() (key.HardwareAttestationKey, error)
}
// SetExtensionHost sets the [ExtensionHost] for the [profileManager].
// The specified host will be notified about profile and prefs changes
// and will immediately be notified about the current profile and prefs.
// A nil host is a valid, no-op host.
func (pm *profileManager) SetExtensionHost(host *ExtensionHost) {
pm.extHost = host
host.NotifyProfileChange(pm.currentProfile, pm.prefs, false)
}
func (pm *profileManager) dlogf(format string, args ...any) {
@@ -64,8 +99,7 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) {
if pm.currentUserID == uid {
return
}
pm.currentUserID = uid
if err := pm.SwitchToDefaultProfile(); err != nil {
if _, _, err := pm.SwitchToDefaultProfileForUser(uid); err != nil {
// SetCurrentUserID should never fail and must always switch to the
// user's default profile or create a new profile for the current user.
// Until we implement multi-user support and the new permission model,
@@ -73,79 +107,122 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) {
// that when SetCurrentUserID exits, the profile in pm.currentProfile
// is either an existing profile owned by the user, or a new, empty profile.
pm.logf("%q's default profile cannot be used; creating a new one: %v", uid, err)
pm.NewProfileForUser(uid)
pm.SwitchToNewProfileForUser(uid)
}
}
// SetCurrentUserAndProfile sets the current user ID and switches the specified
// profile, if it is accessible to the user. If the profile does not exist,
// or is not accessible, it switches to the user's default profile,
// creating a new one if necessary.
// SwitchToProfile switches to the specified profile and (temporarily,
// while the "current user" is still a thing on Windows; see tailscale/corp#18342)
// sets its owner as the current user. The profile must be a valid profile
// returned by the [profileManager], such as by [profileManager.Profiles],
// [profileManager.ProfileByID], or [profileManager.NewProfileForUser].
//
// It is a shorthand for [profileManager.SetCurrentUserID] followed by
// [profileManager.SwitchProfile], but it is more efficient as it switches
// [profileManager.SwitchProfileByID], but it is more efficient as it switches
// directly to the specified profile rather than switching to the user's
// default profile first.
// default profile first. It is a no-op if the specified profile is already
// the current profile.
//
// As a special case, if the specified profile ID "", it creates a new
// profile for the user and switches to it, unless the current profile
// is already a new, empty profile owned by the user.
// As a special case, if the specified profile view is not valid, it resets
// both the current user and the profile to a new, empty profile not owned
// by any user.
//
// It returns the current profile and whether the call resulted
// in a profile switch.
func (pm *profileManager) SetCurrentUserAndProfile(uid ipn.WindowsUserID, profileID ipn.ProfileID) (cp ipn.LoginProfileView, changed bool) {
pm.currentUserID = uid
if profileID == "" {
if pm.currentProfile.ID() == "" && pm.currentProfile.LocalUserID() == uid {
return pm.currentProfile, false
// It returns the current profile and whether the call resulted in a profile change,
// or an error if the specified profile does not exist or its prefs could not be loaded.
//
// It may be called during [profileManager] initialization before [newProfileManager] returns
// and must check whether pm.currentProfile is Valid before using it.
func (pm *profileManager) SwitchToProfile(profile ipn.LoginProfileView) (cp ipn.LoginProfileView, changed bool, err error) {
prefs := defaultPrefs
switch {
case !profile.Valid():
// Create a new profile that is not associated with any user.
profile = pm.NewProfileForUser("")
case profile == pm.currentProfile,
profile.ID() != "" && pm.currentProfile.Valid() && profile.ID() == pm.currentProfile.ID(),
profile.ID() == "" && profile.Equals(pm.currentProfile) && prefs.Equals(pm.prefs):
// The profile is already the current profile; no need to switch.
//
// It includes three cases:
// 1. The target profile and the current profile are aliases referencing the [ipn.LoginProfile].
// The profile may be either a new (non-persisted) profile or an existing well-known profile.
// 2. The target profile is a well-known, persisted profile with the same ID as the current profile.
// 3. The target and the current profiles are both new (non-persisted) profiles and they are equal.
// At minimum, equality means that the profiles are owned by the same user on platforms that support it
// and the prefs are the same as well.
return pm.currentProfile, false, nil
case profile.ID() == "":
// Copy the specified profile to prevent accidental mutation.
profile = profile.AsStruct().View()
default:
// Find an existing profile by ID and load its prefs.
kp, ok := pm.knownProfiles[profile.ID()]
if !ok {
// The profile ID is not valid; it may have been deleted or never existed.
// As the target profile should have been returned by the [profileManager],
// this is unexpected and might indicate a bug in the code.
return pm.currentProfile, false, fmt.Errorf("[unexpected] %w: %s (%s)", errProfileNotFound, profile.Name(), profile.ID())
}
pm.NewProfileForUser(uid)
return pm.currentProfile, true
}
if profile, err := pm.ProfileByID(profileID); err == nil {
if pm.CurrentProfile().ID() == profileID {
return pm.currentProfile, false
}
if err := pm.SwitchProfile(profile.ID()); err == nil {
return pm.currentProfile, true
profile = kp
if prefs, err = pm.loadSavedPrefs(profile.Key()); err != nil {
return pm.currentProfile, false, fmt.Errorf("failed to load profile prefs for %s (%s): %w", profile.Name(), profile.ID(), err)
}
}
if err := pm.SwitchToDefaultProfile(); err != nil {
pm.logf("%q's default profile cannot be used; creating a new one: %v", uid, err)
pm.NewProfile()
if profile.ID() == "" { // new profile that has never been persisted
metricNewProfile.Add(1)
} else {
metricSwitchProfile.Add(1)
}
return pm.currentProfile, true
pm.prefs = prefs
pm.updateHealth()
pm.currentProfile = profile
pm.currentUserID = profile.LocalUserID()
if err := pm.setProfileAsUserDefault(profile); err != nil {
// This is not a fatal error; we've already switched to the profile.
// But if updating the default profile fails, we should log it.
pm.logf("failed to set %s (%s) as the default profile: %v", profile.Name(), profile.ID(), err)
}
if f := pm.StateChangeHook; f != nil {
f(pm.currentProfile, pm.prefs, false)
}
// Do not call pm.extHost.NotifyProfileChange here; it is invoked in
// [LocalBackend.resetForProfileChangeLockedOnEntry] 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.
return profile, true, nil
}
// DefaultUserProfileID returns [ipn.ProfileID] of the default (last used) profile for the specified user,
// or an empty string if the specified user does not have a default profile.
func (pm *profileManager) DefaultUserProfileID(uid ipn.WindowsUserID) ipn.ProfileID {
// DefaultUserProfile returns a read-only view of the default (last used) profile for the specified user.
// It returns a read-only view of a new, non-persisted profile if the specified user does not have a default profile.
func (pm *profileManager) DefaultUserProfile(uid ipn.WindowsUserID) ipn.LoginProfileView {
// Read the CurrentProfileKey from the store which stores
// the selected profile for the specified user.
b, err := pm.store.ReadState(ipn.CurrentProfileKey(string(uid)))
pm.dlogf("DefaultUserProfileID: ReadState(%q) = %v, %v", string(uid), len(b), err)
pm.dlogf("DefaultUserProfile: ReadState(%q) = %v, %v", string(uid), len(b), err)
if err == ipn.ErrStateNotExist || len(b) == 0 {
if runtime.GOOS == "windows" {
pm.dlogf("DefaultUserProfileID: windows: migrating from legacy preferences")
profile, err := pm.migrateFromLegacyPrefs(uid, false)
pm.dlogf("DefaultUserProfile: windows: migrating from legacy preferences")
profile, err := pm.migrateFromLegacyPrefs(uid)
if err == nil {
return profile.ID()
return profile
}
pm.logf("failed to migrate from legacy preferences: %v", err)
}
return ""
return pm.NewProfileForUser(uid)
}
pk := ipn.StateKey(string(b))
prof := pm.findProfileByKey(uid, pk)
if !prof.Valid() {
pm.dlogf("DefaultUserProfileID: no profile found for key: %q", pk)
return ""
pm.dlogf("DefaultUserProfile: no profile found for key: %q", pk)
return pm.NewProfileForUser(uid)
}
return prof.ID()
return prof
}
// checkProfileAccess returns an [errProfileAccessDenied] if the current user
@@ -251,12 +328,6 @@ func (pm *profileManager) setUnattendedModeAsConfigured() error {
}
}
// Reset unloads the current profile, if any.
func (pm *profileManager) Reset() {
pm.currentUserID = ""
pm.NewProfile()
}
// SetPrefs sets the current profile's prefs to the provided value.
// It also saves the prefs to the [ipn.StateStore]. It stores a copy of the
// provided prefs, which may be accessed via [profileManager.CurrentPrefs].
@@ -288,13 +359,37 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
delete(pm.knownProfiles, p.ID())
}
}
pm.currentProfile = cp
// TODO(nickkhyl): Revisit how we handle implicit switching to a different profile,
// which occurs when prefsIn represents a node/user different from that of the
// currentProfile. It happens when a login (either reauth or user-initiated login)
// is completed with a different node/user identity than the one currently in use.
//
// Currently, we overwrite the existing profile prefs with the ones from prefsIn,
// where prefsIn is the previous profile's prefs with an updated Persist, LoggedOut,
// 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
// node/profile-specific state may not be reset as expected.
//
// However, [profileManager] notifies [ipnext.Extension]s about the profile change,
// so features migrated from LocalBackend to external packages should not be affected.
//
// See tailscale/corp#28014.
if !cp.Equals(pm.currentProfile) {
const sameNode = false // implicit profile switch
pm.currentProfile = cp
pm.prefs = prefsIn.AsStruct().View()
if f := pm.StateChangeHook; f != nil {
f(cp, prefsIn, sameNode)
}
pm.extHost.NotifyProfileChange(cp, prefsIn, sameNode)
}
cp, err := pm.setProfilePrefs(nil, prefsIn, np)
if err != nil {
return err
}
return pm.setProfileAsUserDefault(cp)
}
// setProfilePrefs is like [profileManager.SetPrefs], but sets prefs for the specified [ipn.LoginProfile],
@@ -351,7 +446,20 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
// Update the current profile view to reflect the changes
// if the specified profile is the current profile.
if isCurrentProfile {
pm.currentProfile = lp.View()
// Always set pm.currentProfile to the new profile view for pointer equality.
// We check it further down the call stack.
lp := lp.View()
sameProfileInfo := lp.Equals(pm.currentProfile)
pm.currentProfile = lp
if !sameProfileInfo {
// But only invoke the callbacks if the profile info has actually changed.
const sameNode = true // just an info update; still the same node
pm.prefs = prefsIn.AsStruct().View() // suppress further callbacks for this change
if f := pm.StateChangeHook; f != nil {
f(lp, prefsIn, sameNode)
}
pm.extHost.NotifyProfileChange(lp, prefsIn, sameNode)
}
}
// An empty profile.ID indicates that the node info is not available yet,
@@ -392,7 +500,33 @@ func newUnusedID(knownProfiles map[ipn.ProfileID]ipn.LoginProfileView) (ipn.Prof
func (pm *profileManager) setProfilePrefsNoPermCheck(profile ipn.LoginProfileView, clonedPrefs ipn.PrefsView) error {
isCurrentProfile := pm.currentProfile == profile
if isCurrentProfile {
oldPrefs := pm.prefs
pm.prefs = clonedPrefs
// Sadly, profile prefs can be changed in multiple ways.
// It's pretty chaotic, and in many cases callers use
// unexported methods of the profile manager instead of
// going through [LocalBackend.setPrefsLockedOnEntry]
// or at least using [profileManager.SetPrefs].
//
// While we should definitely clean this up to improve
// the overall structure of how prefs are set, which would
// also address current and future conflicts, such as
// competing features changing the same prefs, this method
// is currently the central place where we can detect all
// changes to the current profile's prefs.
//
// That said, regardless of the cleanup, we might want
// to keep the profileManager responsible for invoking
// profile- and prefs-related callbacks.
if !clonedPrefs.Equals(oldPrefs) {
if f := pm.StateChangeHook; f != nil {
f(pm.currentProfile, clonedPrefs, true)
}
pm.extHost.NotifyProfilePrefsChanged(pm.currentProfile, oldPrefs, clonedPrefs)
}
pm.updateHealth()
}
if profile.Key() != "" {
@@ -477,42 +611,32 @@ func (pm *profileManager) profilePrefs(p ipn.LoginProfileView) (ipn.PrefsView, e
return pm.loadSavedPrefs(p.Key())
}
// SwitchProfile switches to the profile with the given id.
// SwitchToProfileByID switches to the profile with the given id.
// It returns the current profile and whether the call resulted in a profile change.
// If the profile exists but is not accessible to the current user, it returns an [errProfileAccessDenied].
// If the profile does not exist, it returns an [errProfileNotFound].
func (pm *profileManager) SwitchProfile(id ipn.ProfileID) error {
metricSwitchProfile.Add(1)
kp, ok := pm.knownProfiles[id]
if !ok {
return errProfileNotFound
func (pm *profileManager) SwitchToProfileByID(id ipn.ProfileID) (_ ipn.LoginProfileView, changed bool, err error) {
if id == pm.currentProfile.ID() {
return pm.currentProfile, false, nil
}
if pm.currentProfile.Valid() && kp.ID() == pm.currentProfile.ID() && pm.prefs.Valid() {
return nil
}
if err := pm.checkProfileAccess(kp); err != nil {
return fmt.Errorf("%w: profile %q is not accessible to the current user", err, id)
}
prefs, err := pm.loadSavedPrefs(kp.Key())
profile, err := pm.ProfileByID(id)
if err != nil {
return err
return pm.currentProfile, false, err
}
pm.prefs = prefs
pm.updateHealth()
pm.currentProfile = kp
return pm.setProfileAsUserDefault(kp)
return pm.SwitchToProfile(profile)
}
// SwitchToDefaultProfile switches to the default (last used) profile for the current user.
// It creates a new one and switches to it if the current user does not have a default profile,
// SwitchToDefaultProfileForUser switches to the default (last used) profile for the specified user.
// It creates a new one and switches to it if the specified user does not have a default profile,
// or returns an error if the default profile is inaccessible or could not be loaded.
func (pm *profileManager) SwitchToDefaultProfile() error {
if id := pm.DefaultUserProfileID(pm.currentUserID); id != "" {
return pm.SwitchProfile(id)
}
pm.NewProfileForUser(pm.currentUserID)
return nil
func (pm *profileManager) SwitchToDefaultProfileForUser(uid ipn.WindowsUserID) (_ ipn.LoginProfileView, changed bool, err error) {
return pm.SwitchToProfile(pm.DefaultUserProfile(uid))
}
// SwitchToDefaultProfile is like [profileManager.SwitchToDefaultProfileForUser], but switches
// to the default profile for the current user.
func (pm *profileManager) SwitchToDefaultProfile() (_ ipn.LoginProfileView, changed bool, err error) {
return pm.SwitchToDefaultProfileForUser(pm.currentUserID)
}
// setProfileAsUserDefault sets the specified profile as the default for the current user.
@@ -529,8 +653,8 @@ func (pm *profileManager) setProfileAsUserDefault(profile ipn.LoginProfileView)
return pm.WriteState(k, []byte(profile.Key()))
}
func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error) {
bs, err := pm.store.ReadState(key)
func (pm *profileManager) loadSavedPrefs(k ipn.StateKey) (ipn.PrefsView, error) {
bs, err := pm.store.ReadState(k)
if err == ipn.ErrStateNotExist || len(bs) == 0 {
return defaultPrefs, nil
}
@@ -538,10 +662,28 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error
return ipn.PrefsView{}, err
}
savedPrefs := ipn.NewPrefs()
if err := ipn.PrefsFromBytes(bs, savedPrefs); err != nil {
return ipn.PrefsView{}, fmt.Errorf("parsing saved prefs: %v", err)
// if supported by the platform, create an empty hardware attestation key to use when deserializing
// to avoid type exceptions from json.Unmarshaling into an interface{}.
hw, _ := pm.newEmptyHardwareAttestationKey()
savedPrefs.Persist = &persist.Persist{
AttestationKey: hw,
}
pm.logf("using backend prefs for %q: %v", key, savedPrefs.Pretty())
if err := ipn.PrefsFromBytes(bs, savedPrefs); err != nil {
// Try loading again, this time ignoring the AttestationKey contents.
// If that succeeds, there's something wrong with the underlying
// attestation key mechanism (most likely the TPM changed), but we
// should at least proceed with client startup.
origErr := err
savedPrefs.Persist.AttestationKey = &noopAttestationKey{}
if err := ipn.PrefsFromBytes(bs, savedPrefs); err != nil {
return ipn.PrefsView{}, fmt.Errorf("parsing saved prefs: %w", err)
} else {
pm.logf("failed to parse savedPrefs with attestation key (error: %v) but parsing without the attestation key succeeded; will proceed without using the old attestation key", origErr)
}
}
pm.logf("using backend prefs for %q: %v", k, savedPrefs.Pretty())
// Ignore any old stored preferences for https://login.tailscale.com
// as the control server that would override the new default of
@@ -558,7 +700,7 @@ func (pm *profileManager) loadSavedPrefs(key ipn.StateKey) (ipn.PrefsView, error
// cause any EditPrefs calls to fail (other than disabling auto-updates).
//
// Reset AutoUpdate.Apply if we detect such invalid prefs.
if savedPrefs.AutoUpdate.Apply.EqualBool(true) && !clientupdate.CanAutoUpdate() {
if savedPrefs.AutoUpdate.Apply.EqualBool(true) && !feature.CanAutoUpdate() {
savedPrefs.AutoUpdate.Apply.Clear()
}
@@ -590,7 +732,6 @@ var errProfileAccessDenied = errors.New("profile access denied")
// This is useful for deleting the last profile. In other cases, it is
// recommended to call [profileManager.SwitchProfile] first.
func (pm *profileManager) DeleteProfile(id ipn.ProfileID) error {
metricDeleteProfile.Add(1)
if id == pm.currentProfile.ID() {
return pm.deleteCurrentProfile()
}
@@ -610,7 +751,7 @@ func (pm *profileManager) deleteCurrentProfile() error {
}
if pm.currentProfile.ID() == "" {
// Deleting the in-memory only new profile, just create a new one.
pm.NewProfile()
pm.SwitchToNewProfile()
return nil
}
return pm.deleteProfileNoPermCheck(pm.currentProfile)
@@ -620,12 +761,13 @@ func (pm *profileManager) deleteCurrentProfile() error {
// but it doesn't check user's access rights to the profile.
func (pm *profileManager) deleteProfileNoPermCheck(profile ipn.LoginProfileView) error {
if profile.ID() == pm.currentProfile.ID() {
pm.NewProfile()
pm.SwitchToNewProfile()
}
if err := pm.WriteState(profile.Key(), nil); err != nil {
return err
}
delete(pm.knownProfiles, profile.ID())
metricDeleteProfile.Add(1)
return pm.writeKnownProfiles()
}
@@ -637,7 +779,7 @@ func (pm *profileManager) DeleteAllProfilesForUser() error {
currentProfileDeleted := false
writeKnownProfiles := func() error {
if currentProfileDeleted || pm.currentProfile.ID() == "" {
pm.NewProfile()
pm.SwitchToNewProfile()
}
return pm.writeKnownProfiles()
}
@@ -666,6 +808,7 @@ func (pm *profileManager) writeKnownProfiles() error {
if err != nil {
return err
}
metricProfileCount.Set(int64(len(pm.knownProfiles)))
return pm.WriteState(ipn.KnownProfilesStateKey, b)
}
@@ -676,45 +819,25 @@ func (pm *profileManager) updateHealth() {
pm.health.SetAutoUpdatePrefs(pm.prefs.AutoUpdate().Check, pm.prefs.AutoUpdate().Apply)
}
// NewProfile creates and switches to a new unnamed profile. The new profile is
// SwitchToNewProfile creates and switches to a new unnamed profile. The new profile is
// not persisted until [profileManager.SetPrefs] is called with a logged-in user.
func (pm *profileManager) NewProfile() {
pm.NewProfileForUser(pm.currentUserID)
func (pm *profileManager) SwitchToNewProfile() {
pm.SwitchToNewProfileForUser(pm.currentUserID)
}
// NewProfileForUser is like [profileManager.NewProfile], but it switches to the
// SwitchToNewProfileForUser is like [profileManager.SwitchToNewProfile], but it switches to the
// specified user and sets that user as the profile owner for the new profile.
func (pm *profileManager) NewProfileForUser(uid ipn.WindowsUserID) {
pm.currentUserID = uid
metricNewProfile.Add(1)
pm.prefs = defaultPrefs
pm.updateHealth()
newProfile := &ipn.LoginProfile{LocalUserID: uid}
pm.currentProfile = newProfile.View()
func (pm *profileManager) SwitchToNewProfileForUser(uid ipn.WindowsUserID) {
pm.SwitchToProfile(pm.NewProfileForUser(uid))
}
// newProfileWithPrefs creates a new profile with the specified prefs and assigns
// the specified uid as the profile owner. If switchNow is true, it switches to the
// newly created profile immediately. It returns the newly created profile on success,
// or an error on failure.
func (pm *profileManager) newProfileWithPrefs(uid ipn.WindowsUserID, prefs ipn.PrefsView, switchNow bool) (ipn.LoginProfileView, error) {
metricNewProfile.Add(1)
// zeroProfile is a read-only view of a new, empty profile that is not persisted to the store.
var zeroProfile = (&ipn.LoginProfile{}).View()
profile, err := pm.setProfilePrefs(&ipn.LoginProfile{LocalUserID: uid}, prefs, ipn.NetworkProfile{})
if err != nil {
return ipn.LoginProfileView{}, err
}
if switchNow {
pm.currentProfile = profile
pm.prefs = prefs.AsStruct().View()
pm.updateHealth()
if err := pm.setProfileAsUserDefault(profile); err != nil {
return ipn.LoginProfileView{}, err
}
}
return profile, nil
// NewProfileForUser creates a new profile for the specified user and returns a read-only view of it.
// It neither switches to the new profile nor persists it to the store.
func (pm *profileManager) NewProfileForUser(uid ipn.WindowsUserID) ipn.LoginProfileView {
return (&ipn.LoginProfile{LocalUserID: uid}).View()
}
// defaultPrefs is the default prefs for a new profile. This initializes before
@@ -742,7 +865,10 @@ func (pm *profileManager) CurrentPrefs() ipn.PrefsView {
// ReadStartupPrefsForTest reads the startup prefs from disk. It is only used for testing.
func ReadStartupPrefsForTest(logf logger.Logf, store ipn.StateStore) (ipn.PrefsView, error) {
ht := new(health.Tracker) // in tests, don't care about the health status
testenv.AssertInTest()
bus := eventbus.New()
defer bus.Close()
ht := health.NewTracker(bus) // in tests, don't care about the health status
pm, err := newProfileManager(store, logf, ht)
if err != nil {
return ipn.PrefsView{}, err
@@ -798,35 +924,20 @@ func newProfileManagerWithGOOS(store ipn.StateStore, logf logger.Logf, ht *healt
return nil, err
}
metricProfileCount.Set(int64(len(knownProfiles)))
pm := &profileManager{
goos: goos,
store: store,
knownProfiles: knownProfiles,
logf: logf,
health: ht,
goos: goos,
store: store,
knownProfiles: knownProfiles,
logf: logf,
health: ht,
newEmptyHardwareAttestationKey: key.NewEmptyHardwareAttestationKey,
}
var initialProfile ipn.LoginProfileView
if stateKey != "" {
for _, v := range knownProfiles {
if v.Key() == stateKey {
pm.currentProfile = v
}
}
if !pm.currentProfile.Valid() {
if suf, ok := strings.CutPrefix(string(stateKey), "user-"); ok {
pm.currentUserID = ipn.WindowsUserID(suf)
}
pm.NewProfile()
} else {
pm.currentUserID = pm.currentProfile.LocalUserID()
}
prefs, err := pm.loadSavedPrefs(stateKey)
if err != nil {
return nil, err
}
if err := pm.setProfilePrefsNoPermCheck(pm.currentProfile, prefs); err != nil {
return nil, err
}
initialProfile = pm.findProfileByKey("", stateKey)
// Most platform behavior is controlled by the goos parameter, however
// some behavior is implied by build tag and fails when run on Windows,
// so we explicitly avoid that behavior when running on Windows.
@@ -837,17 +948,24 @@ func newProfileManagerWithGOOS(store ipn.StateStore, logf logger.Logf, ht *healt
} else if len(knownProfiles) == 0 && goos != "windows" && runtime.GOOS != "windows" {
// No known profiles, try a migration.
pm.dlogf("no known profiles; trying to migrate from legacy prefs")
if _, err := pm.migrateFromLegacyPrefs(pm.currentUserID, true); err != nil {
return nil, err
}
} else {
pm.NewProfile()
}
if initialProfile, err = pm.migrateFromLegacyPrefs(pm.currentUserID); err != nil {
}
}
if !initialProfile.Valid() {
var initialUserID ipn.WindowsUserID
if suf, ok := strings.CutPrefix(string(stateKey), "user-"); ok {
initialUserID = ipn.WindowsUserID(suf)
}
initialProfile = pm.NewProfileForUser(initialUserID)
}
if _, _, err := pm.SwitchToProfile(initialProfile); err != nil {
return nil, err
}
return pm, nil
}
func (pm *profileManager) migrateFromLegacyPrefs(uid ipn.WindowsUserID, switchNow bool) (ipn.LoginProfileView, error) {
func (pm *profileManager) migrateFromLegacyPrefs(uid ipn.WindowsUserID) (ipn.LoginProfileView, error) {
metricMigration.Add(1)
sentinel, prefs, err := pm.loadLegacyPrefs(uid)
if err != nil {
@@ -855,7 +973,7 @@ func (pm *profileManager) migrateFromLegacyPrefs(uid ipn.WindowsUserID, switchNo
return ipn.LoginProfileView{}, fmt.Errorf("load legacy prefs: %w", err)
}
pm.dlogf("loaded legacy preferences; sentinel=%q", sentinel)
profile, err := pm.newProfileWithPrefs(uid, prefs, switchNow)
profile, err := pm.setProfilePrefs(&ipn.LoginProfile{LocalUserID: uid}, prefs, ipn.NetworkProfile{})
if err != nil {
metricMigrationError.Add(1)
return ipn.LoginProfileView{}, fmt.Errorf("migrating _daemon profile: %w", err)
@@ -877,8 +995,27 @@ var (
metricSwitchProfile = clientmetric.NewCounter("profiles_switch")
metricDeleteProfile = clientmetric.NewCounter("profiles_delete")
metricDeleteAllProfile = clientmetric.NewCounter("profiles_delete_all")
metricProfileCount = clientmetric.NewGauge("profiles_count")
metricMigration = clientmetric.NewCounter("profiles_migration")
metricMigrationError = clientmetric.NewCounter("profiles_migration_error")
metricMigrationSuccess = clientmetric.NewCounter("profiles_migration_success")
)
// noopAttestationKey is a key.HardwareAttestationKey that always successfully
// unmarshals as a zero key.
type noopAttestationKey struct{}
func (n noopAttestationKey) Public() crypto.PublicKey {
panic("noopAttestationKey.Public should not be called; missing IsZero check somewhere?")
}
func (n noopAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
panic("noopAttestationKey.Sign should not be called; missing IsZero check somewhere?")
}
func (n noopAttestationKey) MarshalJSON() ([]byte, error) { return nil, nil }
func (n noopAttestationKey) UnmarshalJSON([]byte) error { return nil }
func (n noopAttestationKey) Close() error { return nil }
func (n noopAttestationKey) Clone() key.HardwareAttestationKey { return n }
func (n noopAttestationKey) IsZero() bool { return true }

File diff suppressed because it is too large Load Diff

34
vendor/tailscale.com/ipn/ipnlocal/serve_disabled.go generated vendored Normal file
View File

@@ -0,0 +1,34 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_serve
// These are temporary (2025-09-13) stubs for when tailscaled is built with the
// ts_omit_serve build tag, disabling serve.
//
// TODO: move serve to a separate package, out of ipnlocal, and delete this
// file. One step at a time.
package ipnlocal
import (
"tailscale.com/ipn"
"tailscale.com/tailcfg"
)
const serveEnabled = false
type localListener = struct{}
func (b *LocalBackend) DeleteForegroundSession(sessionID string) error {
return nil
}
type funnelFlow = struct{}
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
return nil
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux || (darwin && !ios) || freebsd || openbsd
//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh
package ipnlocal

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || (!linux && !darwin && !freebsd && !openbsd)
//go:build ts_omit_ssh || ios || android || (!linux && !darwin && !freebsd && !openbsd && !plan9)
package ipnlocal

View File

@@ -1,35 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"maps"
"slices"
"strings"
"tailscale.com/ipn"
)
// UpdateOutgoingFiles updates b.outgoingFiles to reflect the given updates and
// sends an ipn.Notify with the full list of outgoingFiles.
func (b *LocalBackend) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
b.mu.Lock()
if b.outgoingFiles == nil {
b.outgoingFiles = make(map[string]*ipn.OutgoingFile, len(updates))
}
maps.Copy(b.outgoingFiles, updates)
outgoingFiles := make([]*ipn.OutgoingFile, 0, len(b.outgoingFiles))
for _, file := range b.outgoingFiles {
outgoingFiles = append(outgoingFiles, file)
}
b.mu.Unlock()
slices.SortFunc(outgoingFiles, func(a, b *ipn.OutgoingFile) int {
t := a.Started.Compare(b.Started)
if t != 0 {
return t
}
return strings.Compare(a.Name, b.Name)
})
b.send(ipn.Notify{OutgoingFiles: outgoingFiles})
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_tailnetlock
package ipnlocal
import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/types/netmap"
)
type tkaState struct {
authority *tka.Authority
}
func (b *LocalBackend) initTKALocked() error {
return nil
}
func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsView) error {
return nil
}
func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {}
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
return &ipnstate.NetworkLockStatus{Enabled: false}
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android
//go:build !ios && !android && !ts_omit_webclient
package ipnlocal
@@ -19,14 +19,15 @@ import (
"tailscale.com/client/local"
"tailscale.com/client/web"
"tailscale.com/logtail/backoff"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/tsconst"
"tailscale.com/types/logger"
"tailscale.com/util/backoff"
"tailscale.com/util/mak"
)
const webClientPort = web.ListenPort
const webClientPort = tsconst.WebListenPort
// webClient holds state for the web interface for managing this
// tailscale instance. The web interface is not used by default,
@@ -116,11 +117,12 @@ func (b *LocalBackend) handleWebClientConn(c net.Conn) error {
// for each of the local device's Tailscale IP addresses. This is needed to properly
// route local traffic when using kernel networking mode.
func (b *LocalBackend) updateWebClientListenersLocked() {
if b.netMap == nil {
nm := b.currentNode().NetMap()
if nm == nil {
return
}
addrs := b.netMap.GetAddresses()
addrs := nm.GetAddresses()
for _, pfx := range addrs.All() {
addrPort := netip.AddrPortFrom(pfx.Addr(), webClientPort)
if _, ok := b.webClientListeners[addrPort]; ok {

View File

@@ -1,22 +1,20 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android
//go:build ios || android || ts_omit_webclient
package ipnlocal
import (
"errors"
"net"
"tailscale.com/client/local"
)
const webClientPort = 5252
type webClient struct{}
func (b *LocalBackend) ConfigureWebClient(lc *local.Client) {}
func (b *LocalBackend) ConfigureWebClient(any) {}
func (b *LocalBackend) webClientGetOrInit() error {
return errors.New("not implemented")