Update
This commit is contained in:
65
vendor/tailscale.com/ipn/ipnlocal/autoupdate.go
generated
vendored
65
vendor/tailscale.com/ipn/ipnlocal/autoupdate.go
generated
vendored
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
vendor/tailscale.com/ipn/ipnlocal/autoupdate_disabled.go
generated
vendored
18
vendor/tailscale.com/ipn/ipnlocal/autoupdate_disabled.go
generated
vendored
@@ -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.
|
||||
}
|
||||
3
vendor/tailscale.com/ipn/ipnlocal/bus.go
generated
vendored
3
vendor/tailscale.com/ipn/ipnlocal/bus.go
generated
vendored
@@ -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
|
||||
}
|
||||
|
||||
481
vendor/tailscale.com/ipn/ipnlocal/c2n.go
generated
vendored
481
vendor/tailscale.com/ipn/ipnlocal/c2n.go
generated
vendored
@@ -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)
|
||||
}
|
||||
|
||||
2
vendor/tailscale.com/ipn/ipnlocal/c2n_pprof.go
generated
vendored
2
vendor/tailscale.com/ipn/ipnlocal/c2n_pprof.go
generated
vendored
@@ -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
186
vendor/tailscale.com/ipn/ipnlocal/captiveportal.go
generated
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
115
vendor/tailscale.com/ipn/ipnlocal/cert.go
generated
vendored
115
vendor/tailscale.com/ipn/ipnlocal/cert.go
generated
vendored
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
178
vendor/tailscale.com/ipn/ipnlocal/desktop_sessions.go
generated
vendored
178
vendor/tailscale.com/ipn/ipnlocal/desktop_sessions.go
generated
vendored
@@ -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
|
||||
}
|
||||
233
vendor/tailscale.com/ipn/ipnlocal/drive.go
generated
vendored
233
vendor/tailscale.com/ipn/ipnlocal/drive.go
generated
vendored
@@ -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
30
vendor/tailscale.com/ipn/ipnlocal/drive_tomove.go
generated
vendored
Normal 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)
|
||||
}
|
||||
16
vendor/tailscale.com/ipn/ipnlocal/expiry.go
generated
vendored
16
vendor/tailscale.com/ipn/ipnlocal/expiry.go
generated
vendored
@@ -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
621
vendor/tailscale.com/ipn/ipnlocal/extension_host.go
generated
vendored
Normal 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
48
vendor/tailscale.com/ipn/ipnlocal/hwattest.go
generated
vendored
Normal 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
|
||||
}
|
||||
5100
vendor/tailscale.com/ipn/ipnlocal/local.go
generated
vendored
5100
vendor/tailscale.com/ipn/ipnlocal/local.go
generated
vendored
File diff suppressed because it is too large
Load Diff
74
vendor/tailscale.com/ipn/ipnlocal/netstack.go
generated
vendored
Normal file
74
vendor/tailscale.com/ipn/ipnlocal/netstack.go
generated
vendored
Normal 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
|
||||
}
|
||||
213
vendor/tailscale.com/ipn/ipnlocal/network-lock.go
generated
vendored
213
vendor/tailscale.com/ipn/ipnlocal/network-lock.go
generated
vendored
@@ -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
872
vendor/tailscale.com/ipn/ipnlocal/node_backend.go
generated
vendored
Normal 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
|
||||
// isn’t 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
|
||||
}
|
||||
470
vendor/tailscale.com/ipn/ipnlocal/peerapi.go
generated
vendored
470
vendor/tailscale.com/ipn/ipnlocal/peerapi.go
generated
vendored
@@ -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
110
vendor/tailscale.com/ipn/ipnlocal/peerapi_drive.go
generated
vendored
Normal 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
|
||||
}
|
||||
20
vendor/tailscale.com/ipn/ipnlocal/peerapi_h2c.go
generated
vendored
20
vendor/tailscale.com/ipn/ipnlocal/peerapi_h2c.go
generated
vendored
@@ -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)
|
||||
}
|
||||
}
|
||||
10
vendor/tailscale.com/ipn/ipnlocal/peerapi_macios_ext.go
generated
vendored
10
vendor/tailscale.com/ipn/ipnlocal/peerapi_macios_ext.go
generated
vendored
@@ -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
103
vendor/tailscale.com/ipn/ipnlocal/prefs_metrics.go
generated
vendored
Normal 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
|
||||
}
|
||||
459
vendor/tailscale.com/ipn/ipnlocal/profiles.go
generated
vendored
459
vendor/tailscale.com/ipn/ipnlocal/profiles.go
generated
vendored
@@ -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 }
|
||||
|
||||
871
vendor/tailscale.com/ipn/ipnlocal/serve.go
generated
vendored
871
vendor/tailscale.com/ipn/ipnlocal/serve.go
generated
vendored
File diff suppressed because it is too large
Load Diff
34
vendor/tailscale.com/ipn/ipnlocal/serve_disabled.go
generated
vendored
Normal file
34
vendor/tailscale.com/ipn/ipnlocal/serve_disabled.go
generated
vendored
Normal 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
|
||||
}
|
||||
2
vendor/tailscale.com/ipn/ipnlocal/ssh.go
generated
vendored
2
vendor/tailscale.com/ipn/ipnlocal/ssh.go
generated
vendored
@@ -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
|
||||
|
||||
|
||||
2
vendor/tailscale.com/ipn/ipnlocal/ssh_stub.go
generated
vendored
2
vendor/tailscale.com/ipn/ipnlocal/ssh_stub.go
generated
vendored
@@ -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
|
||||
|
||||
|
||||
35
vendor/tailscale.com/ipn/ipnlocal/taildrop.go
generated
vendored
35
vendor/tailscale.com/ipn/ipnlocal/taildrop.go
generated
vendored
@@ -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})
|
||||
}
|
||||
31
vendor/tailscale.com/ipn/ipnlocal/tailnetlock_disabled.go
generated
vendored
Normal file
31
vendor/tailscale.com/ipn/ipnlocal/tailnetlock_disabled.go
generated
vendored
Normal 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}
|
||||
}
|
||||
12
vendor/tailscale.com/ipn/ipnlocal/web_client.go
generated
vendored
12
vendor/tailscale.com/ipn/ipnlocal/web_client.go
generated
vendored
@@ -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 {
|
||||
|
||||
6
vendor/tailscale.com/ipn/ipnlocal/web_client_stub.go
generated
vendored
6
vendor/tailscale.com/ipn/ipnlocal/web_client_stub.go
generated
vendored
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user