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

No files matched your search

+408 -22
View File
@@ -1,10 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -14,9 +17,9 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/types/empty"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/structs"
"tailscale.com/types/views"
"tailscale.com/util/syspolicy/policyclient"
)
type State int
@@ -35,15 +38,27 @@ const (
// ID tokens used by the Android client.
const GoogleIDTokenType = "ts_android_google_login"
var stateStrings = [...]string{
"NoState",
"InUseOtherUser",
"NeedsLogin",
"NeedsMachineAuth",
"Stopped",
"Starting",
"Running",
}
func (s State) String() string {
return [...]string{
"NoState",
"InUseOtherUser",
"NeedsLogin",
"NeedsMachineAuth",
"Stopped",
"Starting",
"Running"}[s]
return stateStrings[s]
}
// StateFromString parses s as a State string value.
func StateFromString(s string) (_ State, ok bool) {
i := slices.Index(stateStrings[:], s)
if i == -1 {
return NoState, false
}
return State(i), true
}
// EngineStatus contains WireGuard engine stats.
@@ -70,9 +85,19 @@ const (
// each one via Engine.RequestStatus.
NotifyWatchEngineUpdates NotifyWatchOpt = 1 << 0
NotifyInitialState NotifyWatchOpt = 1 << 1 // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL + SessionID
NotifyInitialPrefs NotifyWatchOpt = 1 << 2 // if set, the first Notify message (sent immediately) will contain the current Prefs
NotifyInitialNetMap NotifyWatchOpt = 1 << 3 // if set, the first Notify message (sent immediately) will contain the current NetMap
NotifyInitialState NotifyWatchOpt = 1 << 1 // if set, the first Notify message (sent immediately) will contain the current State + BrowseToURL + SessionID
NotifyInitialPrefs NotifyWatchOpt = 1 << 2 // if set, the first Notify message (sent immediately) will contain the current Prefs
// ObsoleteNotifyInitialNetMap is a formerly valid bit (previously named
// NotifyInitialNetMap) that asked for the first Notify message to
// carry the full netmap in the since-removed Notify.NetMap field.
// Subscription requests that set it are now rejected by
// [ValidateNotifyWatchOpt]. Watchers seed their view from
// [NotifyInitialStatus] instead, and those that need more than the
// status fetch what they need via other LocalAPI methods. The bit
// value remains reserved so it is never reused with a different
// meaning.
ObsoleteNotifyInitialNetMap NotifyWatchOpt = 1 << 3
NotifyNoPrivateKeys NotifyWatchOpt = 1 << 4 // (no-op) it used to redact private keys; now they always are and this does nothing
NotifyInitialDriveShares NotifyWatchOpt = 1 << 5 // if set, the first Notify message (sent immediately) will contain the current Taildrive Shares
@@ -80,18 +105,209 @@ const (
NotifyInitialHealthState NotifyWatchOpt = 1 << 7 // if set, the first Notify message (sent immediately) will contain the current health.State of the client
NotifyRateLimit NotifyWatchOpt = 1 << 8 // if set, rate limit spammy netmap updates to every few seconds
// ObsoleteNotifyRateLimit is a formerly valid bit (previously named
// NotifyRateLimit) that asked tailscaled to rate limit spammy netmap
// updates to every few seconds. It became meaningless once tailscaled
// stopped emitting [Notify.NetMap] on runtime (non-initial) bus
// messages, so subscription requests that set it are now rejected by
// [ValidateNotifyWatchOpt]. The bit value remains reserved so it is
// never reused with a different meaning.
ObsoleteNotifyRateLimit NotifyWatchOpt = 1 << 8
NotifyHealthActions NotifyWatchOpt = 1 << 9 // if set, include PrimaryActions in health.State. Otherwise append the action URL to the text
NotifyInitialSuggestedExitNode NotifyWatchOpt = 1 << 10 // if set, the first Notify message (sent immediately) will contain the current SuggestedExitNode if available
NotifyInitialClientVersion NotifyWatchOpt = 1 << 11 // if set, the first Notify message (sent immediately) will contain the current ClientVersion if available and if update checks are enabled
// NotifyPeerChanges, if set, opts the watcher into peer-set delta
// notifications: [Notify.PeersChanged] (peer added or full-Node
// replaced) and [Notify.PeersRemoved] (peer removed by NodeID).
//
// Without this bit, peer adds/removes/replacements are not delivered
// over the bus at all (consumers fall back to fetching the netmap on
// demand).
//
// Watchers that want narrower per-field updates as well (Online,
// LastSeen, DERPHome, Endpoints) should additionally set
// [NotifyPeerPatches]. Without [NotifyPeerPatches], any per-field
// patch tailscaled would have emitted as a [tailcfg.PeerChange] is
// promoted into a full-Node entry in [Notify.PeersChanged] for this
// watcher, so a watcher that opts only into [NotifyPeerChanges] still
// observes every per-peer mutation; it just receives them as full
// Nodes rather than narrow patches. The cost is bus bandwidth.
//
// Watchers typically pair this with [NotifyInitialStatus] to seed
// their initial state.
NotifyPeerChanges NotifyWatchOpt = 1 << 12
// NotifyNoNetMap historically suppressed the legacy Notify.NetMap
// field on runtime (non-initial) Notify messages on platforms where
// tailscaled still emitted it by default. That field no longer
// exists, so this bit is now a no-op. It remains accepted for
// compatibility with clients that set it.
NotifyNoNetMap NotifyWatchOpt = 1 << 13
// NotifyInitialStatus, if set, causes the first Notify message (sent
// immediately) to contain the current [ipnstate.Status] in
// [Notify.InitialStatus]. Together with [Notify.SelfChange] and
// [Notify.PeersChanged] on subsequent messages, it lets a watcher
// stitch together a continuous view of the local node's state as a
// stable, client-facing snapshot type, without needing the netmap.
//
// The status is sized to the subscription: its per-peer entries
// (Status.Peer) are only populated if the watcher also set
// [NotifyPeerChanges] or [NotifyPeerPatches], since building them is
// O(peers) and only peer-delta subscribers need a peer baseline.
// Self-only watchers get Status.Self and the scalar fields.
NotifyInitialStatus NotifyWatchOpt = 1 << 14
// NotifyPeerPatches, if set, opts the watcher into narrow per-field
// peer patches via [Notify.PeerChangedPatch]. It implies
// [NotifyPeerChanges]: a watcher with [NotifyPeerPatches] also
// receives [Notify.PeersChanged] and [Notify.PeersRemoved].
//
// This is the lower-bandwidth mode: changes to fields that fit in a
// [tailcfg.PeerChange] (currently Online, LastSeen, DERPHome,
// Endpoints) ride as patches; only changes that don't fit ride as
// full Nodes in [Notify.PeersChanged].
//
// Without this bit but with [NotifyPeerChanges], the producer
// promotes any patch into a full-Node entry in [Notify.PeersChanged]
// for this session, at the cost of bandwidth.
NotifyPeerPatches NotifyWatchOpt = 1 << 15
// NotifyInProcessNoDisconnect, if set, marks this watcher as an
// in-process subscriber that must not be disconnected for falling behind
// on its notification queue. Instead, if its queue fills, Notify
// production blocks until the watcher catches up.
//
// Callers using this bit must receive and process notifications promptly.
// Their callbacks must not call back into LocalBackend or wait on work that
// might call back into LocalBackend, because the producer might be holding
// LocalBackend's mutex while waiting for the watcher to catch up.
//
// This bit is only valid for in-process callers of
// LocalBackend.WatchNotificationsAs. LocalAPI WatchIPNBus clients must
// not request it.
NotifyInProcessNoDisconnect NotifyWatchOpt = 1 << 16
// NotifySysPolicyChanges, if set, causes the first Notify message, which is sent
// immediately, to contain the current effective [setting.Snapshot] in
// [Notify.Policy]. [Notify.Policy] is included in subsequent messages whenever
// the effective policy changes.
//
// The snapshot is scoped to the connected user's identity (on Windows,
// derived from the named-pipe token's SID).
//
// The [setting.Snapshot] that is delivered is a full snapshot on every
// change.
NotifySysPolicyChanges NotifyWatchOpt = 1 << 17
// NotifyPeerWireGuardState, if set, opts the watcher into
// WireGuard session state notifications via [Notify.PeerState].
// The first Notify sent to the watcher includes a dump of current
// non-zero peer states, and subsequent Notifies include per-peer
// state changes.
NotifyPeerWireGuardState NotifyWatchOpt = 1 << 18
)
// String implements the [fmt.Stringer] interface.
// Returns the string representation of all the bits joined by the bitwise-or "|" operator.
func (o NotifyWatchOpt) String() string {
if o == NotifyWatchOpt(0) {
return fmt.Sprintf("%T(%#x)", o, uint64(o))
}
pkg, _, found := strings.Cut(fmt.Sprintf("%T", o), ".")
var bits []string
var mask NotifyWatchOpt
try := func(bit NotifyWatchOpt, s string) {
if o&bit == 0 {
return
}
if found {
bits = append(bits, pkg+"."+s)
} else {
bits = append(bits, s)
}
mask |= bit
}
try(NotifyWatchEngineUpdates, "NotifyWatchEngineUpdates")
try(NotifyInitialState, "NotifyInitialState")
try(NotifyInitialPrefs, "NotifyInitialPrefs")
try(ObsoleteNotifyInitialNetMap, "ObsoleteNotifyInitialNetMap")
try(NotifyNoPrivateKeys, "NotifyNoPrivateKeys")
try(NotifyInitialDriveShares, "NotifyInitialDriveShares")
try(NotifyInitialOutgoingFiles, "NotifyInitialOutgoingFiles")
try(NotifyInitialHealthState, "NotifyInitialHealthState")
try(ObsoleteNotifyRateLimit, "ObsoleteNotifyRateLimit")
try(NotifyHealthActions, "NotifyHealthActions")
try(NotifyInitialSuggestedExitNode, "NotifyInitialSuggestedExitNode")
try(NotifyInitialClientVersion, "NotifyInitialClientVersion")
try(NotifyPeerChanges, "NotifyPeerChanges")
try(NotifyNoNetMap, "NotifyNoNetMap")
try(NotifyInitialStatus, "NotifyInitialStatus")
try(NotifyPeerPatches, "NotifyPeerPatches")
try(NotifyInProcessNoDisconnect, "NotifyInProcessNoDisconnect")
try(NotifySysPolicyChanges, "NotifySysPolicyChanges")
try(NotifyPeerWireGuardState, "NotifyPeerWireGuardState")
if mask != o {
bits = append(bits, fmt.Sprintf("%T(%#x)", o, uint64(o^mask))) // unknown
}
if len(bits) == 1 {
return bits[0]
}
// Multiple values, so we need to wrap with parentheses.
return "(" + strings.Join(bits, " | ") + ")"
}
// AppendText implements the [encoding.TextAppender] interface
// by encoding its textual representation.
func (o NotifyWatchOpt) AppendText(b []byte) ([]byte, error) {
return strconv.AppendUint(b, uint64(o), 10), nil
}
// MarshalText implements the [encoding.TextMarshaler] interface
// by encoding its textual representation.
func (o NotifyWatchOpt) MarshalText() (text []byte, err error) {
return o.AppendText(nil)
}
// UnmarshalText implements the [encoding.TextUnmarshaler] interface
// by decoding its textual representation.
func (o *NotifyWatchOpt) UnmarshalText(text []byte) error {
v, err := strconv.ParseUint(string(text), 10, 64)
if err != nil {
return err
}
*o = NotifyWatchOpt(v)
return nil
}
// ValidateNotifyWatchOpt reports whether mask is a valid WatchIPNBus
// subscription mask.
func ValidateNotifyWatchOpt(mask NotifyWatchOpt) error {
if mask&ObsoleteNotifyRateLimit != 0 {
return errors.New("the NotifyRateLimit IPN bus subscription bit is no longer supported")
}
if mask&ObsoleteNotifyInitialNetMap != 0 {
return errors.New("the NotifyInitialNetMap IPN bus subscription bit is no longer supported; seed from NotifyInitialStatus instead")
}
return nil
}
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
// (cmd/tailscale, iOS, macOS, Win Tasktray).
// In any given notification, any or all of these may be nil, meaning
// that they have not changed.
// They are JSON-encoded on the wire, despite the lack of struct tags.
//
// API maturity: this type is not considered a stable API and is
// subject to change between releases.
type Notify struct {
_ structs.Incomparable
Version string // version number of IPN backend
@@ -106,12 +322,104 @@ type Notify struct {
// For State InUseOtherUser, ErrMessage is not critical and just contains the details.
ErrMessage *string
LoginFinished *empty.Message // non-nil when/if the login process succeeded
State *State // if non-nil, the new or current IPN state
Prefs *PrefsView // if non-nil && Valid, the new or current preferences
NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
Engine *EngineStatus // if non-nil, the new or current wireguard stats
BrowseToURL *string // if non-nil, UI should open a browser right now
LoginFinished *empty.Message // non-nil when/if the login process succeeded
State *State // if non-nil, the new or current IPN state
Prefs *PrefsView // if non-nil && Valid, the new or current preferences
// SelfChange, if non-nil, indicates that this node's own [tailcfg.Node]
// has changed: addresses, name, key expiry, capabilities, etc. It carries
// the new self node so reactive consumers (containerboot, kube agents,
// sniproxy, etc.) can read the current self state without watching the
// full netmap.
//
// Runtime self changes are not gated by any subscription bit: every
// watcher receives them. There is no initial SelfChange; watchers
// that need the current self node at the start of a session should
// set [NotifyInitialStatus] and seed from InitialStatus.Self.
//
// Consumers that need additional state (peers, DNS config, etc.)
// should react to SelfChange by fetching what they need via other
// LocalAPI methods, such as LocalClient.Status or
// LocalClient.DNSConfig.
SelfChange *tailcfg.Node `json:",omitzero"`
// InitialStatus, if non-nil, is the current [ipnstate.Status]. It is
// only set in the first Notify of a session when the watcher requested
// [NotifyInitialStatus]. Together with subsequent [Notify.SelfChange]
// and [Notify.PeerChanges] messages, it lets a watcher stitch together
// a continuous view of node state without fetching the netmap.
InitialStatus *ipnstate.Status `json:",omitzero"`
// PeerChangedPatch, if non-empty, lists narrow per-field peer patches
// since the last Notify (currently Online, LastSeen, DERPHome,
// Endpoints). It mirrors [tailcfg.MapResponse.PeersChangedPatch].
//
// Peer additions and any peer change that can't be expressed as a
// [tailcfg.PeerChange] travel in [Notify.PeersChanged]; peer removals
// in [Notify.PeersRemoved].
//
// Watchers must opt in to receive this field by setting
// [NotifyPeerPatches]; without that bit (but with [NotifyPeerChanges])
// the producer promotes each patch into a full-Node entry in
// [Notify.PeersChanged] instead.
//
// The [tailcfg.PeerChange] type may grow more fields over time;
// consumers that see a [tailcfg.PeerChange] with a field they don't
// recognize should re-fetch the affected node by NodeID via
// [LocalClient.PeerByID] (an O(1) lookup) to learn its current value
// rather than ignoring the change.
PeerChangedPatch []*tailcfg.PeerChange `json:",omitzero"`
// PeersChanged, if non-empty, lists peers whose full [tailcfg.Node]
// has been added or replaced since the last Notify. A node ID may
// appear here either because it is a brand-new peer or because the
// control plane sent a fresh full Node for an existing peer when the
// change wasn't expressible as a [tailcfg.PeerChange] patch (e.g. a
// CapMap, Addresses, Hostinfo, or Tags change). Consumers should
// upsert by NodeID.
//
// This mirrors [tailcfg.MapResponse.PeersChanged] semantics; peer
// removals travel in [Notify.PeersRemoved] and narrow per-field
// patches in [Notify.PeerChanges].
PeersChanged []*tailcfg.Node `json:",omitzero"`
// PeersRemoved, if non-empty, lists [tailcfg.NodeID]s that have been
// removed from the netmap since the last Notify. See
// [Notify.PeersChanged]. This mirrors
// [tailcfg.MapResponse.PeersRemoved].
PeersRemoved []tailcfg.NodeID `json:",omitzero"`
// UserProfiles, if non-empty, carries [tailcfg.UserProfileView]
// entries that have been added or updated since the last Notify on
// this session. Watchers must opt in via [NotifyPeerChanges] or
// [NotifyPeerPatches]; this field is gated on the same bits as
// [Notify.PeersChanged] / [Notify.PeerChangedPatch] because its
// only purpose is to let those consumers resolve the [tailcfg.UserID]
// referenced by a peer Node.
//
// The producer guarantees that any UserID referenced by a peer in
// a [Notify.PeersChanged] / [Notify.PeerChangedPatch] entry will
// have its profile delivered either earlier on this same session
// (via an earlier Notify carrying UserProfiles) or in this same
// Notify. A consumer that sees a
// UserID it doesn't recognize on a session that opted in to
// peer-change notifications can treat it as a bug; the
// [LocalClient.UserProfile] LocalAPI fallback exists for sessions
// that didn't subscribe with the peer-change bits or that need to
// look up a UserID for any other reason.
//
// The values are [tailcfg.UserProfileView] so they share backing
// memory with the producer's tracking maps; consumers should treat
// them as read-only and use [tailcfg.UserProfileView.AsStruct] or
// the per-field accessors to read them.
UserProfiles map[tailcfg.UserID]tailcfg.UserProfileView `json:",omitzero"`
// PeerState, if non-empty, carries WireGuard session states keyed by stable
// node ID. Watchers must opt in via [NotifyPeerWireGuardState].
PeerState map[tailcfg.StableNodeID]PeerState `json:",omitzero"`
Engine *EngineStatus // if non-nil, the new or current wireguard stats
BrowseToURL *string // if non-nil, UI should open a browser right now
// FilesWaiting if non-nil means that files are buffered in
// the Tailscale daemon and ready for local transfer to the
@@ -161,9 +469,81 @@ type Notify struct {
// be the best exit node for the current network conditions.
SuggestedExitNode *tailcfg.StableNodeID `json:",omitzero"`
// Policy, if non-nil, is the effective policy snapshot for the
// connected user. It is scoped per-user: per-user policy settings
// are merged with device-wide settings, with device-wide taking
// precedence. Sent initially when [NotifySysPolicyChanges] is set,
// and on change thereafter.
Policy *policyclient.PolicySnapshot `json:",omitzero"`
// type is mirrored in xcode/IPN/Core/LocalAPI/Model/LocalAPIModel.swift
}
// PeerWireGuardState is the WireGuard session state for a peer.
//
// It JSON-marshals as a lowercase string (e.g. "handshake", "established")
// rather than its integer value, so the wire format does not depend on the
// numeric constants below.
type PeerWireGuardState uint8
const (
PeerWireGuardStateNone PeerWireGuardState = 0
PeerWireGuardStateHandshake PeerWireGuardState = 1
PeerWireGuardStateEstablished PeerWireGuardState = 2
PeerWireGuardStateExpired PeerWireGuardState = 3
)
// String returns the lowercase string form of s used by [PeerWireGuardState.MarshalText].
func (s PeerWireGuardState) String() string {
switch s {
case PeerWireGuardStateNone:
return "none"
case PeerWireGuardStateHandshake:
return "handshake"
case PeerWireGuardStateEstablished:
return "established"
case PeerWireGuardStateExpired:
return "expired"
}
return fmt.Sprintf("PeerWireGuardState(%d)", uint8(s))
}
// MarshalText implements [encoding.TextMarshaler].
func (s PeerWireGuardState) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
func (s *PeerWireGuardState) UnmarshalText(b []byte) error {
switch string(b) {
case "none":
*s = PeerWireGuardStateNone
case "handshake":
*s = PeerWireGuardStateHandshake
case "established":
*s = PeerWireGuardStateEstablished
case "expired":
*s = PeerWireGuardStateExpired
default:
return fmt.Errorf("unknown PeerWireGuardState %q", b)
}
return nil
}
// PeerState is the per-peer WireGuard session state delivered in
// [Notify.PeerState].
type PeerState struct {
// PeerWireGuardState is the current WireGuard session state for the peer.
PeerWireGuardState PeerWireGuardState
// PeerWireGuardStateAt is the wall-clock time at which the peer entered
// [PeerState.PeerWireGuardState], as observed by tailscaled.
// It is tracked by [LocalBackend] even when no watchers are subscribed,
// so a later subscriber's initial snapshot reflects the true transition
// time rather than the subscription time.
PeerWireGuardStateAt time.Time
}
func (n Notify) String() string {
var sb strings.Builder
sb.WriteString("Notify{")
@@ -179,8 +559,14 @@ func (n Notify) String() string {
if n.Prefs != nil && n.Prefs.Valid() {
fmt.Fprintf(&sb, "%v ", n.Prefs.Pretty())
}
if n.NetMap != nil {
sb.WriteString("NetMap{...} ")
if n.SelfChange != nil {
fmt.Fprintf(&sb, "SelfChange(%v) ", n.SelfChange.StableID)
}
if n.PeerChangedPatch != nil {
fmt.Fprintf(&sb, "PeerChangedPatch(%d) ", len(n.PeerChangedPatch))
}
if len(n.PeerState) > 0 {
fmt.Fprintf(&sb, "PeerState(%d) ", len(n.PeerState))
}
if n.Engine != nil {
fmt.Fprintf(&sb, "wg=%v ", *n.Engine)
+47 -4
View File
@@ -1,11 +1,14 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (
"errors"
"fmt"
"net/netip"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/types/preftype"
@@ -29,8 +32,9 @@ type ConfigVAlpha struct {
ExitNode *string `json:"exitNode,omitempty"` // IP, StableID, or MagicDNS base name
AllowLANWhileUsingExitNode opt.Bool `json:"allowLANWhileUsingExitNode,omitempty"`
AdvertiseRoutes []netip.Prefix `json:",omitempty"`
DisableSNAT opt.Bool `json:",omitempty"`
AdvertiseRoutes []netip.Prefix `json:",omitempty"`
AdvertiseExitNode opt.Bool `json:",omitzero"`
DisableSNAT opt.Bool `json:",omitzero"`
AdvertiseServices []string `json:",omitempty"`
@@ -43,6 +47,7 @@ type ConfigVAlpha struct {
RunSSHServer opt.Bool `json:",omitempty"` // Tailscale SSH
RunWebClient opt.Bool `json:",omitempty"`
ShieldsUp opt.Bool `json:",omitempty"`
RemoteConfig opt.Bool `json:",omitzero"` // delegate full remote control to the tailnet admin; see Prefs.RemoteConfig
AutoUpdate *AutoUpdatePrefs `json:",omitempty"`
ServeConfigTemp *ServeConfig `json:",omitempty"` // TODO(bradfitz,maisem): make separate stable type for this
@@ -50,6 +55,15 @@ type ConfigVAlpha struct {
// should advertise amongst its wireguard endpoints.
StaticEndpoints []netip.AddrPort `json:",omitempty"`
// RelayServerPort is the UDP port for the relay server to bind to.
// A value of 0 will pick a random unused port. Nil disables relay server.
RelayServerPort *uint16 `json:",omitzero"`
// RelayServerStaticEndpoints are static IP:port endpoints to advertise
// as candidates for relay connections. Only relevant when RelayServerPort
// is non-nil.
RelayServerStaticEndpoints []netip.AddrPort `json:",omitempty"`
// TODO(bradfitz,maisem): future something like:
// Profile map[string]*Config // keyed by alice@gmail.com, corp.com (TailnetSID)
}
@@ -101,12 +115,29 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
mp.ExitNodeAllowLANAccessSet = true
}
if c.AdvertiseRoutes != nil {
var routeErrs []error
for _, route := range c.AdvertiseRoutes {
if route != route.Masked() {
routeErrs = append(routeErrs, fmt.Errorf("route %s has non-address bits set; expected %s", route, route.Masked()))
}
}
if err := errors.Join(routeErrs...); err != nil {
return mp, err
}
mp.AdvertiseRoutes = c.AdvertiseRoutes
mp.AdvertiseRoutesSet = true
}
if c.AdvertiseExitNode.EqualBool(true) {
if mp.AdvertiseRoutesSet {
mp.AdvertiseRoutes = append(mp.AdvertiseRoutes, tsaddr.AllIPv4(), tsaddr.AllIPv6())
} else {
mp.AdvertiseRoutes = tsaddr.ExitRoutes()
mp.AdvertiseRoutesSet = true
}
}
if c.DisableSNAT != "" {
mp.NoSNAT = c.DisableSNAT.EqualBool(true)
mp.NoSNAT = true
mp.NoSNATSet = true
}
if c.NoStatefulFiltering != "" {
mp.NoStatefulFiltering = c.NoStatefulFiltering
@@ -137,6 +168,10 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
mp.ShieldsUp = c.ShieldsUp.EqualBool(true)
mp.ShieldsUpSet = true
}
if c.RemoteConfig != "" {
mp.RemoteConfig = c.RemoteConfig.EqualBool(true)
mp.RemoteConfigSet = true
}
if c.AutoUpdate != nil {
mp.AutoUpdate = *c.AutoUpdate
mp.AutoUpdateSet = AutoUpdatePrefsMask{ApplySet: true, CheckSet: true}
@@ -155,5 +190,13 @@ func (c *ConfigVAlpha) ToPrefs() (MaskedPrefs, error) {
if c.AdvertiseServices != nil {
mp.AdvertiseServices = c.AdvertiseServices
}
mp.RelayServerPortSet = true
mp.RelayServerStaticEndpointsSet = true
if c.RelayServerPort != nil {
mp.RelayServerPort = c.RelayServerPort
}
if c.RelayServerStaticEndpoints != nil {
mp.RelayServerStaticEndpoints = c.RelayServerStaticEndpoints
}
return mp, nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conffile
+15 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package conffile contains code to load, manipulate, and access config file
@@ -8,6 +8,7 @@ package conffile
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
@@ -40,6 +41,15 @@ func (c *Config) WantRunning() bool {
// from the VM's metadata service's user-data field.
const VMUserDataPath = "vm:user-data"
// ErrNoConfig is returned (wrapped) by Load when the config source is absent or
// unreadable: a missing file, an EC2 instance with no user-data, an unreachable
// metadata service, or a build without the relevant cloud support. It reports
// "no config was provided", as distinct from "a config was provided but is
// invalid" (which Load returns as an unwrapped parse/validate error). Callers
// that want to boot unconfigured when no config is present (e.g. tailscaled's
// "optional:" -config prefix) can check for it with errors.Is.
var ErrNoConfig = errors.New("no config present")
// hujsonStandardize is set to hujson.Standardize by conffile_hujson.go on
// platforms that support config files.
var hujsonStandardize func([]byte) ([]byte, error)
@@ -62,7 +72,10 @@ func Load(path string) (*Config, error) {
c.Raw, err = os.ReadFile(path)
}
if err != nil {
return nil, err
// Read-phase failure: the config source is absent or unreadable.
// Wrap ErrNoConfig so callers can distinguish this from a config
// that was read but failed to parse below.
return nil, fmt.Errorf("%w: %v", ErrNoConfig, err)
}
if buildfeatures.HasHuJSONConf && hujsonStandardize != nil {
c.Std, err = hujsonStandardize(c.Raw)
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !ts_omit_hujsonconf
+98 -17
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
@@ -15,17 +15,34 @@ import (
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/util/mak"
)
// LegacyVersion is the sentinel [ServicesConfigFile.Version] used to mark a
// config that was loaded from the legacy raw [ipn.ServeConfig] format (a
// version-less file, such as "tailscale serve status --json" output). When
// Version is LegacyVersion, [ServicesConfigFile.Legacy] is set and Services is
// nil. It is never written to disk; the on-disk format always uses "0.0.1".
const LegacyVersion = "0.0.0"
// ServicesConfigFile is the config file format for services configuration.
type ServicesConfigFile struct {
// Version is always "0.0.1" and always present.
// Version is "0.0.1" for the declarative services configuration file
// format, or [LegacyVersion] ("0.0.0") when this value was produced by
// [LoadServicesConfig] from a legacy raw ipn.ServeConfig file (in which
// case Legacy is set instead of Services).
Version string `json:"version"`
Services map[tailcfg.ServiceName]*ServiceDetailsFile `json:"services,omitzero"`
// Legacy holds a raw ipn.ServeConfig parsed from a version-less file (e.g.
// "tailscale serve status --json" output). It is non-nil only when Version
// is [LegacyVersion]. It is an in-memory loading artifact and is never
// serialized.
Legacy *ipn.ServeConfig `json:"-"`
}
// ServiceDetailsFile is the config syntax for an individual Tailscale Service.
@@ -75,11 +92,16 @@ type Target struct {
// If Protocol is ProtoFile, then Destination is a file path.
// If Protocol is ProtoTUN, then Destination is empty.
// If Protocol is ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, or
// ProtoTLSTerminatedTCP and Destination starts with "unix:", it is a Unix
// socket path (e.g. "unix:/var/run/app.sock" or "unix:relative.sock").
// Otherwise, it is a host.
Destination string
// If Protocol is not ProtoFile or ProtoTUN, then DestinationPorts is the
// set of ports on which to connect to the host referred to by Destination.
// For unix socket targets (Destination starting with "unix:"),
// DestinationPorts is unused and left at the zero value.
DestinationPorts tailcfg.PortRange
}
@@ -116,13 +138,22 @@ func (t *Target) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
t.Destination = target
t.DestinationPorts = tailcfg.PortRange{}
case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
host, portRange, err := tailcfg.ParseHostPortRange(rest)
if err != nil {
return err
if unixPath, ok := strings.CutPrefix(rest, "unix:"); ok {
if unixPath == "" {
return errors.New("unix socket path cannot be empty")
}
t.Protocol = ServiceProtocol(proto)
t.Destination = rest
t.DestinationPorts = tailcfg.PortRange{}
} else {
host, portRange, err := tailcfg.ParseHostPortRange(rest)
if err != nil {
return err
}
t.Protocol = ServiceProtocol(proto)
t.Destination = host
t.DestinationPorts = portRange
}
t.Protocol = ServiceProtocol(proto)
t.Destination = host
t.DestinationPorts = portRange
default:
return errors.New("unsupported protocol")
}
@@ -138,13 +169,27 @@ func (t *Target) MarshalText() ([]byte, error) {
case ProtoTUN:
out = "TUN"
case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
out = fmt.Sprintf("%s://%s", t.Protocol, net.JoinHostPort(t.Destination, t.DestinationPorts.String()))
if strings.HasPrefix(t.Destination, "unix:") {
// Unix socket: serialize as e.g. "tcp://unix:/path/to/sock"
out = fmt.Sprintf("%s://%s", t.Protocol, t.Destination)
} else {
out = fmt.Sprintf("%s://%s", t.Protocol, net.JoinHostPort(t.Destination, t.DestinationPorts.String()))
}
default:
return nil, errors.New("unsupported protocol")
}
return []byte(out), nil
}
// LoadServicesConfig loads a serve config file as a [ServicesConfigFile].
//
// If the file has a top-level "version" field it is parsed as that versioned
// declarative format. Otherwise it is treated as a legacy raw [ipn.ServeConfig]
// (such as "tailscale serve status --json" emits): the returned
// ServicesConfigFile has Version [LegacyVersion] and its Legacy field set to the
// parsed raw config, with Services left nil.
//
// forService is used only for the versioned Services configuration file format.
func LoadServicesConfig(filename string, forService string) (*ServicesConfigFile, error) {
data, err := os.ReadFile(filename)
if err != nil {
@@ -165,13 +210,38 @@ func LoadServicesConfig(filename string, forService string) (*ServicesConfigFile
if err = jsonv2.Unmarshal(json, &ver); err != nil {
return nil, fmt.Errorf("could not parse config file version: %w", err)
}
switch ver.Version {
case "":
return nil, errors.New("config file must have \"version\" field")
case "0.0.1":
return loadConfigV0(json, forService)
if ver.Version == "" {
// No "version" field. This is either the legacy raw ipn.ServeConfig
// (e.g. "tailscale serve status --json" output, which set-config still
// accepts) or a Services configuration file whose required "version"
// field was omitted. Distinguish them by the Services config format's
// lowercase "services"/"endpoints" keys, which never appear in a raw
// ServeConfig: it uses capitalized "Services" and has no "endpoints"
// key, and jsonv2 matches case-sensitively. Without this check a
// version-less Services config file would parse as an empty
// ServeConfig and silently wipe the existing config.
var probe struct {
Services jsontext.Value `json:"services"`
Endpoints jsontext.Value `json:"endpoints"`
}
if err := jsonv2.Unmarshal(json, &probe); err == nil &&
(len(probe.Services) > 0 || len(probe.Endpoints) > 0) {
return nil, errors.New(`config file looks like a Services configuration file but is missing the required "version" field`)
}
// Legacy raw ipn.ServeConfig: parse leniently (like set-raw and
// TS_SERVE_CONFIG) so "serve status --json" round-trips keep working.
// It is returned wrapped in a ServicesConfigFile with the LegacyVersion
// sentinel so the public function signature stays stable.
legacy := new(ipn.ServeConfig)
if err := jsonv2.Unmarshal(json, legacy); err != nil {
return nil, fmt.Errorf("could not parse serve config: %w", err)
}
return &ServicesConfigFile{Version: LegacyVersion, Legacy: legacy}, nil
}
return nil, fmt.Errorf("unsupported config file version %q", ver.Version)
if ver.Version != "0.0.1" {
return nil, fmt.Errorf("unsupported config file version %q", ver.Version)
}
return loadConfigV0(json, forService)
}
func loadConfigV0(json []byte, forService string) (*ServicesConfigFile, error) {
@@ -210,8 +280,19 @@ func loadConfigV0(json []byte, forService string) (*ServicesConfigFile, error) {
}
foundTUN = true
} else {
if ppr.Ports.Last-ppr.Ports.First != target.DestinationPorts.Last-target.DestinationPorts.First {
return nil, fmt.Errorf("service %q: source and destination port ranges must be of equal size", svcName.String())
// Unix socket targets (Destination starting with "unix:" on an
// HTTP/HTTPS/HTTPSInsecure/TCP/TLSTerminatedTCP inbound protocol)
// don't have a destination port range; skip the equality check.
isUnixSocket := strings.HasPrefix(target.Destination, "unix:") &&
(target.Protocol == ProtoHTTP ||
target.Protocol == ProtoHTTPS ||
target.Protocol == ProtoHTTPSInsecure ||
target.Protocol == ProtoTCP ||
target.Protocol == ProtoTLSTerminatedTCP)
if !isUnixSocket {
if ppr.Ports.Last-ppr.Ports.First != target.DestinationPorts.Last-target.DestinationPorts.First {
return nil, fmt.Errorf("service %q: source and destination port ranges must be of equal size", svcName.String())
}
}
foundNonTUN = true
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/viewer -type=LoginProfile,Prefs,ServeConfig,ServiceConfig,TCPPortHandler,HTTPHandler,WebServerConfig
+10 -7
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
@@ -8,13 +8,14 @@ package ipn
import (
"maps"
"net/netip"
"time"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
"tailscale.com/types/ptr"
)
// Clone makes a deep copy of LoginProfile.
@@ -25,6 +26,7 @@ func (src *LoginProfile) Clone() *LoginProfile {
}
dst := new(LoginProfile)
*dst = *src
dst.UserProfile = *src.UserProfile.Clone()
return dst
}
@@ -38,6 +40,7 @@ var _LoginProfileCloneNeedsRegeneration = LoginProfile(struct {
NodeID tailcfg.StableNodeID
LocalUserID WindowsUserID
ControlURL string
Created time.Time
}{})
// Clone makes a deep copy of Prefs.
@@ -62,7 +65,7 @@ func (src *Prefs) Clone() *Prefs {
}
}
if dst.RelayServerPort != nil {
dst.RelayServerPort = ptr.To(*src.RelayServerPort)
dst.RelayServerPort = new(*src.RelayServerPort)
}
dst.RelayServerStaticEndpoints = append(src.RelayServerStaticEndpoints[:0:0], src.RelayServerStaticEndpoints...)
dst.Persist = src.Persist.Clone()
@@ -101,10 +104,10 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
RemoteConfig bool
DriveShares []*drive.Share
RelayServerPort *uint16
RelayServerStaticEndpoints []netip.AddrPort
AllowSingleHosts marshalAsTrueInJSON
Persist *persist.Persist
}{})
@@ -122,7 +125,7 @@ func (src *ServeConfig) Clone() *ServeConfig {
if v == nil {
dst.TCP[k] = nil
} else {
dst.TCP[k] = ptr.To(*v)
dst.TCP[k] = new(*v)
}
}
}
@@ -184,7 +187,7 @@ func (src *ServiceConfig) Clone() *ServiceConfig {
if v == nil {
dst.TCP[k] = nil
} else {
dst.TCP[k] = ptr.To(*v)
dst.TCP[k] = new(*v)
}
}
}
@@ -245,7 +248,7 @@ var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
Path string
Proxy string
Text string
AcceptAppCaps []tailcfg.PeerCapability
AcceptAppCaps []peercap.Cap
Redirect string
}{})
+39 -18
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
@@ -9,11 +9,13 @@ import (
jsonv1 "encoding/json"
"errors"
"net/netip"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/preftype"
@@ -113,7 +115,7 @@ func (v LoginProfileView) Key() StateKey { return v.ж.Key }
// UserProfile is the server provided UserProfile for this profile.
// This is updated whenever the server provides a new UserProfile.
func (v LoginProfileView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile }
func (v LoginProfileView) UserProfile() tailcfg.UserProfileView { return v.ж.UserProfile.View() }
// NodeID is the NodeID of the node that this profile is logged into.
// This should be stable across tagging and untagging nodes.
@@ -131,6 +133,12 @@ func (v LoginProfileView) LocalUserID() WindowsUserID { return v.ж.LocalUserID
// into.
func (v LoginProfileView) ControlURL() string { return v.ж.ControlURL }
// Created is when this profile was first added to this client. It is
// stamped once at profile creation and never changes. It is used to sort
// the profile list with newest first; profiles created before this field
// existed have a zero value and sort after all stamped profiles.
func (v LoginProfileView) Created() time.Time { return v.ж.Created }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _LoginProfileViewNeedsRegeneration = LoginProfile(struct {
ID ProfileID
@@ -141,6 +149,7 @@ var _LoginProfileViewNeedsRegeneration = LoginProfile(struct {
NodeID tailcfg.StableNodeID
LocalUserID WindowsUserID
ControlURL string
Created time.Time
}{})
// View returns a read-only view of Prefs.
@@ -283,7 +292,8 @@ func (v PrefsView) InternalExitNodePrior() tailcfg.StableNodeID { return v.ж.In
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
// CorpDNS specifies whether to install the Tailscale network's
// DNS configuration, if it exists.
// DNS configuration, if it exists. It is the internal name for
// the "tailscale set --accept-dns=" flag.
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
// RunSSH bool is whether this node should run an SSH
@@ -378,7 +388,7 @@ func (v PrefsView) Sync() opt.Bool { return v.ж.Sync }
// network to route Tailscale traffic back to the subnet relay
// machine.
//
// Linux-only.
// Linux and FreeBSD only.
func (v PrefsView) NoSNAT() bool { return v.ж.NoSNAT }
// NoStatefulFiltering specifies whether to apply stateful filtering when
@@ -432,6 +442,24 @@ func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
// Linux-only.
func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind }
// RemoteConfig, if true, delegates full remote control of this node's
// prefs and LocalAPI to the tailnet admin via the control plane. When
// enabled, the control server can read and edit any of this node's
// prefs at any time, and invoke any LocalAPI endpoint on this node,
// without any further local consent (no CLI or GUI confirmation).
//
// This is an alternative to Tailscale's default per-feature double
// opt-in model, in which both the tailnet admin and the local machine
// owner must agree to each individual setting change. RemoteConfig is
// a single client-side "I trust the tailnet admin" switch that hands
// over full remote management of this node.
//
// Only enable this when the tailnet admin owns the machine (e.g. a
// corporate fleet device) or the local user has explicitly delegated
// full control to the tailnet admin. Do NOT enable this on personal
// or BYOD devices where the tailnet admin is not fully trusted.
func (v PrefsView) RemoteConfig() bool { return v.ж.RemoteConfig }
// DriveShares are the configured DriveShares, stored in increasing order
// by name.
func (v PrefsView) DriveShares() views.SliceView[*drive.Share, drive.ShareView] {
@@ -453,16 +481,6 @@ func (v PrefsView) RelayServerStaticEndpoints() views.Slice[netip.AddrPort] {
return views.SliceOf(v.ж.RelayServerStaticEndpoints)
}
// AllowSingleHosts was a legacy field that was always true
// for the past 4.5 years. It controlled whether Tailscale
// peers got /32 or /128 routes for each other.
// As of 2024-05-17 we're starting to ignore it, but to let
// people still downgrade Tailscale versions and not break
// all peer-to-peer networking we still write it to disk (as JSON)
// so it can be loaded back by old versions.
// TODO(bradfitz): delete this in 2025 sometime. See #12058.
func (v PrefsView) AllowSingleHosts() marshalAsTrueInJSON { return v.ж.AllowSingleHosts }
// The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -503,10 +521,10 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
RemoteConfig bool
DriveShares []*drive.Share
RelayServerPort *uint16
RelayServerStaticEndpoints []netip.AddrPort
AllowSingleHosts marshalAsTrueInJSON
Persist *persist.Persist
}{})
@@ -807,7 +825,10 @@ func (v TCPPortHandlerView) HTTPS() bool { return v.ж.HTTPS }
// It is mutually exclusive with TCPForward.
func (v TCPPortHandlerView) HTTP() bool { return v.ж.HTTP }
// TCPForward is the IP:port to forward TCP connections to.
// TCPForward is the address to forward TCP connections to.
// It is either a host:port (e.g. "127.0.0.1:3128", "localhost:5432")
// or a Unix socket path prefixed with "unix:"
// (e.g. "unix:/var/run/app.sock" or "unix:relative.sock").
// Whether or not TLS is terminated by tailscaled depends on
// TerminateTLS.
//
@@ -912,7 +933,7 @@ func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy }
func (v HTTPHandlerView) Text() string { return v.ж.Text }
// peer capabilities to forward in grant header, e.g. example.com/cap/mon
func (v HTTPHandlerView) AcceptAppCaps() views.Slice[tailcfg.PeerCapability] {
func (v HTTPHandlerView) AcceptAppCaps() views.Slice[peercap.Cap] {
return views.SliceOf(v.ж.AcceptAppCaps)
}
@@ -930,7 +951,7 @@ var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
Path string
Proxy string
Text string
AcceptAppCaps []tailcfg.PeerCapability
AcceptAppCaps []peercap.Cap
Redirect string
}{})
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package ipnauth controls access to the LocalAPI.
+4 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows && ts_omit_unixsocketidentity
@@ -15,7 +15,9 @@ import (
// based on the user who owns the other end of the connection.
// and couldn't. The returned connIdentity has NotWindows set to true.
func GetConnIdentity(_ logger.Logf, c net.Conn) (ci *ConnIdentity, err error) {
return &ConnIdentity{conn: c, notWindows: true}, nil
ci = &ConnIdentity{conn: c, notWindows: true}
_, ci.isUnixSock = c.(*net.UnixConn)
return ci, nil
}
// WindowsToken is unsupported when GOOS != windows and always returns
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows && !ts_omit_unixsocketidentity
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
+149 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package ipnext defines types and interfaces used for extending the core LocalBackend
@@ -6,6 +6,7 @@
package ipnext
import (
"context"
"errors"
"fmt"
"iter"
@@ -17,10 +18,15 @@ import (
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/tsd"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/mapx"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/wgengine/filter"
)
// Extension augments LocalBackend with additional functionality.
@@ -109,7 +115,14 @@ var extensions mapx.OrderedMap[string, *Definition]
//
// It panics if newExt is nil or if an extension with the same name
// has already been registered.
//
// As a backstop for feature packages that forget to consult
// feature.Register, it does nothing if the named feature was disabled
// via the TS_DISABLE_FEATURE environment variable.
func RegisterExtension(name string, newExt NewExtensionFn) {
if feature.Disabled(name) {
return
}
if newExt == nil {
panic(fmt.Sprintf("ipnext: newExt is nil: %q", name))
}
@@ -201,6 +214,16 @@ type Host interface {
// NodeBackend returns the [NodeBackend] for the currently active node
// (which is approximately the same as the current profile).
NodeBackend() NodeBackend
// AuthReconfigAsync asynchronously pushes a new configuration into wgengine,
// if engine updates are not currently blocked, based on the cached netmap and
// user prefs. The reconfiguration is applied to [ipnlocal.LocalBackend]'s currently
// active node at the time of execution.
//
// AuthReconfigAsync should not be called at a high rate (i.e., more often
// than prefs and netmap changes), except in experimental or proof-of-concept
// contexts, since reconfiguration is known to be slow.
AuthReconfigAsync()
}
// SafeBackend is a subset of the [ipnlocal.LocalBackend] type's methods that
@@ -212,6 +235,33 @@ type SafeBackend interface {
TailscaleVarRoot() string
}
// NotifyWatcher is a subset of [tailscale.com/ipn/ipnlocal.LocalBackend]
// for extensions that subscribe to the IPN notification bus from within tailscaled.
//
// Unlike [SafeBackend], its methods acquire LocalBackend’s internal mutex
// and must not be called from extension hooks,
// instead call them from a goroutine started by [Extension.Init].
type NotifyWatcher interface {
// WatchNotifications subscribes to the ipn.Notify message bus notification
// messages.
//
// WatchNotifications blocks until ctx is done.
//
// The provided onWatchAdded, if non-nil, will be called once the watcher
// is installed.
//
// The provided fn will be called for each notification. It will only be
// called with non-nil pointers. The caller must not modify roNotify. If
// fn returns false, the watch also stops.
//
// Failure to consume many notifications in a row will result in one final
// notification with ErrMessage set, followed by the watch closing, unless mask
// includes ipn.NotifyInProcessNoDisconnect. Watchers using
// NotifyInProcessNoDisconnect must not call back into LocalBackend from fn or
// wait on work that might call back into LocalBackend.
WatchNotifications(ctx context.Context, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool))
}
// ExtensionServices provides access to the [Host]'s extension management services,
// such as fetching active extensions.
type ExtensionServices interface {
@@ -362,6 +412,12 @@ type Hooks struct {
// is created. It is called with the LocalBackend locked.
NewControlClient feature.Hooks[NewControlClientCallback]
// OnNetMapToggle is called (with LocalBackend.mu held) when the network map
// is toggled from nil to non-nil, or non-nil to nil. This usually happens
// when the client connects to the control plane and receives the initial MapResponse,
// or when the client disconnects and the network map is cleared.
OnNetMapToggle feature.Hooks[func(*netmap.NetworkMap)]
// OnSelfChange is called (with LocalBackend.mu held) when the self node
// changes, including changing to nothing (an invalid view).
OnSelfChange feature.Hooks[func(tailcfg.NodeView)]
@@ -377,21 +433,108 @@ type Hooks struct {
// ShouldUploadServices reports whether this node should include services
// in Hostinfo from the portlist extension.
ShouldUploadServices feature.Hook[func() bool]
// Filter contains hooks for the packet filter.
// See [filter.Filter] for details on how these hooks are invoked.
Filter FilterHooks
// ExtraWireGuardAllowedIPs is called with a sequence of peers whose
// extra AllowedIPs the caller wants (re)computed, and returns
// prefixes to append to those peers' AllowedIPs, keyed by node ID.
//
// The sequence is not necessarily all peers: callers may pass any
// subset (such as only the peers changed by a netmap delta), and
// the returned map's meaning is scoped to the peers presented. A
// peer absent from the returned map has no extra AllowedIPs. As of
// 2026-07-15 the only caller passes all current peers on each
// reconfig, but extensions must not rely on that.
//
// An extension with nothing to add should return nil without
// iterating peers; that keeps steady-state netmap deltas free of
// per-peer work when the extension is idle. The peers sequence is
// only valid during the call.
//
// The extra AllowedIPs are given to WireGuard, but not the OS
// routing table.
//
// The returned prefixes should not contain duplicates, either
// internally, or with netmap peer prefixes. They should only
// contain host routes, and not contain default or subnet routes.
// Subsequent calls that return an unchanged set of prefixes for a
// given peer should return the prefixes in the same order for that
// peer, to prevent configuration churn.
//
// The returned map and slices should not be mutated by the
// extension after they are returned.
//
// The hook is called with LocalBackend's mutex locked.
//
// TODO(#17858): This hook may not be needed and can possibly be replaced by
// new hooks that fit into the new architecture that make use of new
// WireGuard APIs.
ExtraWireGuardAllowedIPs feature.Hook[func(peers iter.Seq2[tailcfg.NodeID, key.NodePublic]) map[tailcfg.NodeID][]netip.Prefix]
// ExtraRouterConfigRoutes returns a view of prefixes to append to [router.Config.Routes].
//
// Routes goes through the WireGuard engine which makes efforts to avoid
// unnecessary reconfiguration by checking that things have actually changed.
// So implementors should make sure that the order of the prefixes is stable
// and that we don't have duplicate entries.
//
// The returned slice should not be mutated by the extension after it is returned.
//
// The hook is called with LocalBackend's mutex locked.
ExtraRouterConfigRoutes feature.Hook[func() views.Slice[netip.Prefix]]
}
// FilterHooks contains hooks that extensions can use to customize the packet
// filter. Field names match the corresponding fields in filter.Filter.
type FilterHooks struct {
// IngressAllowHooks are hooks that allow extensions to accept inbound
// packets beyond the standard filter rules. Packets that are not dropped
// by the direction-agnostic pre-check, but would be not accepted by the
// main filter rules, including the check for destinations in the node's
// local IP set, will be accepted if they match one of these hooks.
// As of 2026-02-24, the ingress filter does not implement explicit drop
// rules, but if it does, an explicitly dropped packet will be dropped,
// and these hooks will not be evaluated.
//
// Processing of hooks stop after the first one that returns true.
// The returned why string of the first match is used in logging.
// Returning false does not drop the packet.
// See also [filter.Filter.IngressAllowHooks].
IngressAllowHooks feature.Hooks[filter.PacketMatch]
// LinkLocalAllowHooks are hooks that provide exceptions to the default
// policy of dropping link-local unicast packets. They run inside the
// direction-agnostic pre-checks for both ingress and egress.
//
// A hook can allow a link-local packet to pass the link-local check,
// but the packet is still subject to all other filter rules, and could be
// dropped elsewhere. Matching link-local packets are not logged.
// See also [filter.Filter.LinkLocalAllowHooks].
LinkLocalAllowHooks feature.Hooks[filter.PacketMatch]
}
// NodeBackend is an interface to query the current node and its peers.
//
// It is not a snapshot in time but is locked to a particular node.
type NodeBackend interface {
// Self returns the current node.
Self() tailcfg.NodeView
// AppendMatchingPeers appends all peers that match the predicate
// to the base slice and returns it.
AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView
// Peers returns all the current peers.
Peers() []tailcfg.NodeView
// PeerCaps returns the capabilities that src has to this node.
PeerCaps(src netip.Addr) tailcfg.PeerCapMap
// PeerHasCap reports whether the peer has the specified peer capability.
PeerHasCap(peer tailcfg.NodeView, cap tailcfg.PeerCapability) bool
PeerHasCap(peer tailcfg.NodeView, cap peercap.Cap) bool
// 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
@@ -408,4 +551,8 @@ type NodeBackend interface {
// node that the portlist service collection is desirable, should it
// choose to report them.
CollectServices() bool
// DERPMap returns the current DERP map from the current netmap,
// or nil if there is no netmap.
DERPMap() *tailcfg.DERPMap
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -15,7 +15,7 @@ func init() {
func breakTCPConnsDarwin() error {
var matched int
for fd := 0; fd < 1000; fd++ {
for fd := range 1000 {
_, err := unix.GetsockoptTCPConnectionInfo(fd, unix.IPPROTO_TCP, unix.TCP_CONNECTION_INFO)
if err == nil {
matched++
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -15,7 +15,7 @@ func init() {
func breakTCPConnsLinux() error {
var matched int
for fd := 0; fd < 1000; fd++ {
for fd := range 1000 {
_, err := unix.GetsockoptTCPInfo(fd, unix.IPPROTO_TCP, unix.TCP_INFO)
if err == nil {
matched++
-161
View File
@@ -1,161 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"time"
"tailscale.com/ipn"
"tailscale.com/tstime"
)
type rateLimitingBusSender struct {
fn func(*ipn.Notify) (keepGoing bool)
lastFlush time.Time // last call to fn, or zero value if none
interval time.Duration // 0 to flush immediately; non-zero to rate limit sends
clock tstime.DefaultClock // non-nil for testing
didSendTestHook func() // non-nil for testing
// pending, if non-nil, is the pending notification that we
// haven't sent yet. We own this memory to mutate.
pending *ipn.Notify
// flushTimer is non-nil if the timer is armed.
flushTimer tstime.TimerController // effectively a *time.Timer
flushTimerC <-chan time.Time // ... said ~Timer's C chan
}
func (s *rateLimitingBusSender) close() {
if s.flushTimer != nil {
s.flushTimer.Stop()
}
}
func (s *rateLimitingBusSender) flushChan() <-chan time.Time {
return s.flushTimerC
}
func (s *rateLimitingBusSender) flush() (keepGoing bool) {
if n := s.pending; n != nil {
s.pending = nil
return s.flushNotify(n)
}
return true
}
func (s *rateLimitingBusSender) flushNotify(n *ipn.Notify) (keepGoing bool) {
s.lastFlush = s.clock.Now()
return s.fn(n)
}
// send conditionally sends n to the underlying fn, possibly rate
// limiting it, depending on whether s.interval is set, and whether
// n is a notable notification that the client (typically a GUI) would
// want to act on (render) immediately.
//
// It returns whether the caller should keep looping.
//
// The passed-in memory 'n' is owned by the caller and should
// not be mutated.
func (s *rateLimitingBusSender) send(n *ipn.Notify) (keepGoing bool) {
if s.interval <= 0 {
// No rate limiting case.
return s.fn(n)
}
if isNotableNotify(n) {
// Notable notifications are always sent immediately.
// But first send any boring one that was pending.
// TODO(bradfitz): there might be a boring one pending
// with a NetMap or Engine field that is redundant
// with the new one (n) with NetMap or Engine populated.
// We should clear the pending one's NetMap/Engine in
// that case. Or really, merge the two, but mergeBoringNotifies
// only handles the case of both sides being boring.
// So for now, flush both.
if !s.flush() {
return false
}
return s.flushNotify(n)
}
s.pending = mergeBoringNotifies(s.pending, n)
d := s.clock.Now().Sub(s.lastFlush)
if d > s.interval {
return s.flush()
}
nextFlushIn := s.interval - d
if s.flushTimer == nil {
s.flushTimer, s.flushTimerC = s.clock.NewTimer(nextFlushIn)
} else {
s.flushTimer.Reset(nextFlushIn)
}
return true
}
func (s *rateLimitingBusSender) Run(ctx context.Context, ch <-chan *ipn.Notify) {
for {
select {
case <-ctx.Done():
return
case n, ok := <-ch:
if !ok {
return
}
if !s.send(n) {
return
}
if f := s.didSendTestHook; f != nil {
f()
}
case <-s.flushChan():
if !s.flush() {
return
}
}
}
}
// mergeBoringNotify merges new notify 'src' into possibly-nil 'dst',
// either mutating 'dst' or allocating a new one if 'dst' is nil,
// returning the merged result.
//
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
if dst == nil {
dst = &ipn.Notify{Version: src.Version}
}
if src.NetMap != nil {
dst.NetMap = src.NetMap
}
if src.Engine != nil {
dst.Engine = src.Engine
}
return dst
}
// isNotableNotify reports whether n is a "notable" notification that
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.
//
// It effectively reports whether n contains any field set that's
// not NetMap or Engine.
func isNotableNotify(n *ipn.Notify) bool {
if n == nil {
return false
}
return n.State != nil ||
n.SessionID != "" ||
n.BrowseToURL != nil ||
n.LocalTCPPort != nil ||
n.ClientVersion != nil ||
n.Prefs != nil ||
n.ErrMessage != nil ||
n.LoginFinished != nil ||
!n.DriveShares.IsNil() ||
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
len(n.OutgoingFiles) > 0 ||
n.FilesWaiting != nil ||
n.SuggestedExitNode != nil
}
+31 -24
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -44,9 +44,6 @@ func init() {
// several candidate nodes is reachable and actually alive.
RegisterC2N("/echo", handleC2NEcho)
}
if buildfeatures.HasSSH {
RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
}
if buildfeatures.HasLogTail {
RegisterC2N("POST /logtail/flush", handleC2NLogtailFlush)
}
@@ -86,6 +83,29 @@ func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *htt
c2nHandlers[k] = h
}
// RegisterC2NPrefix registers h as the c2n handler for all paths starting
// with prefix. prefix must end in "/". Prefix matches are tried after all
// exact-path handlers registered via [RegisterC2N] fail to match.
func RegisterC2NPrefix(prefix string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasC2N {
return
}
if prefix == "" || !strings.HasSuffix(prefix, "/") {
panic(fmt.Sprintf("c2n: prefix %q must be non-empty and end with /", prefix))
}
c2nPrefixHandlers = append(c2nPrefixHandlers, c2nPrefixHandler{prefix, h})
}
// c2nPrefixHandler is a c2n handler that matches all paths starting with prefix.
type c2nPrefixHandler struct {
prefix string
h c2nHandler
}
// c2nPrefixHandlers are c2n handlers matched by URL path prefix rather than
// exact path. See [RegisterC2NPrefix].
var c2nPrefixHandlers []c2nPrefixHandler
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
type methodAndPath struct {
@@ -121,6 +141,13 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
h(b, w, r)
return
}
// Then try prefix matches.
for _, ph := range c2nPrefixHandlers {
if strings.HasPrefix(r.URL.Path, ph.prefix) {
ph.h(b, w, r)
return
}
}
if c2nHandlerPaths.Contains(r.URL.Path) {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
} else {
@@ -290,26 +317,6 @@ func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
c2nPprof(w, r, profile)
}
func handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasSSH {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
var req tailcfg.C2NSSHUsernamesRequest
if r.Method == "POST" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
res, err := b.getSSHUsernames(&req)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, res)
}
func handleC2NSockStats(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if b.sockstatLogger == nil {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !wasm && !ts_omit_debug
-186
View File
@@ -1,186 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_captiveportal
package ipnlocal
import (
"context"
"time"
"tailscale.com/health"
"tailscale.com/net/captivedetection"
"tailscale.com/util/clientmetric"
)
func init() {
hookCaptivePortalHealthChange.Set(captivePortalHealthChange)
hookCheckCaptivePortalLoop.Set(checkCaptivePortalLoop)
}
var metricCaptivePortalDetected = clientmetric.NewCounter("captiveportal_detected")
// captivePortalDetectionInterval is the duration to wait in an unhealthy state with connectivity broken
// before running captive portal detection.
const captivePortalDetectionInterval = 2 * time.Second
func captivePortalHealthChange(b *LocalBackend, state *health.State) {
isConnectivityImpacted := false
for _, w := range state.Warnings {
// Ignore the captive portal warnable itself.
if w.ImpactsConnectivity && w.WarnableCode != captivePortalWarnable.Code {
isConnectivityImpacted = true
break
}
}
// captiveCtx can be changed, and is protected with 'mu'; grab that
// before we start our select, below.
//
// It is guaranteed to be non-nil.
b.mu.Lock()
ctx := b.captiveCtx
b.mu.Unlock()
// If the context is canceled, we don't need to do anything.
if ctx.Err() != nil {
return
}
if isConnectivityImpacted {
b.logf("health: connectivity impacted; triggering captive portal detection")
// Ensure that we select on captiveCtx so that we can time out
// triggering captive portal detection if the backend is shutdown.
select {
case b.needsCaptiveDetection <- true:
case <-ctx.Done():
}
} else {
// If connectivity is not impacted, we know for sure we're not behind a captive portal,
// so drop any warning, and signal that we don't need captive portal detection.
b.health.SetHealthy(captivePortalWarnable)
select {
case b.needsCaptiveDetection <- false:
case <-ctx.Done():
}
}
}
// captivePortalWarnable is a Warnable which is set to an unhealthy state when a captive portal is detected.
var captivePortalWarnable = health.Register(&health.Warnable{
Code: "captive-portal-detected",
Title: "Captive portal detected",
// High severity, because captive portals block all traffic and require user intervention.
Severity: health.SeverityHigh,
Text: health.StaticMessage("This network requires you to log in using your web browser."),
ImpactsConnectivity: true,
})
func checkCaptivePortalLoop(b *LocalBackend, ctx context.Context) {
var tmr *time.Timer
maybeStartTimer := func() {
// If there's an existing timer, nothing to do; just continue
// waiting for it to expire. Otherwise, create a new timer.
if tmr == nil {
tmr = time.NewTimer(captivePortalDetectionInterval)
}
}
maybeStopTimer := func() {
if tmr == nil {
return
}
if !tmr.Stop() {
<-tmr.C
}
tmr = nil
}
for {
if ctx.Err() != nil {
maybeStopTimer()
return
}
// First, see if we have a signal on our "healthy" channel, which
// takes priority over an existing timer. Because a select is
// nondeterministic, we explicitly check this channel before
// entering the main select below, so that we're guaranteed to
// stop the timer before starting captive portal detection.
select {
case needsCaptiveDetection := <-b.needsCaptiveDetection:
if needsCaptiveDetection {
maybeStartTimer()
} else {
maybeStopTimer()
}
default:
}
var timerChan <-chan time.Time
if tmr != nil {
timerChan = tmr.C
}
select {
case <-ctx.Done():
// All done; stop the timer and then exit.
maybeStopTimer()
return
case <-timerChan:
// Kick off captive portal check
b.performCaptiveDetection()
// nil out timer to force recreation
tmr = nil
case needsCaptiveDetection := <-b.needsCaptiveDetection:
if needsCaptiveDetection {
maybeStartTimer()
} else {
// Healthy; cancel any existing timer
maybeStopTimer()
}
}
}
}
// shouldRunCaptivePortalDetection reports whether captive portal detection
// should be run. It is enabled by default, but can be disabled via a control
// knob. It is also only run when the user explicitly wants the backend to be
// running.
func (b *LocalBackend) shouldRunCaptivePortalDetection() bool {
b.mu.Lock()
defer b.mu.Unlock()
return !b.ControlKnobs().DisableCaptivePortalDetection.Load() && b.pm.prefs.WantRunning()
}
// performCaptiveDetection checks if captive portal detection is enabled via controlknob. If so, it runs
// the detection and updates the Warnable accordingly.
func (b *LocalBackend) performCaptiveDetection() {
if !b.shouldRunCaptivePortalDetection() {
return
}
d := captivedetection.NewDetector(b.logf)
b.mu.Lock() // for b.hostinfo
cn := b.currentNode()
dm := cn.DERPMap()
preferredDERP := 0
if b.hostinfo != nil {
if b.hostinfo.NetInfo != nil {
preferredDERP = b.hostinfo.NetInfo.PreferredDERP
}
}
ctx := b.ctx
netMon := b.NetMon()
b.mu.Unlock()
found := d.Detect(ctx, netMon, dm, preferredDERP)
if found {
if !b.health.IsUnhealthy(captivePortalWarnable) {
metricCaptivePortalDetected.Add(1)
}
b.health.SetUnhealthy(captivePortalWarnable, health.Args{})
} else {
b.health.SetHealthy(captivePortalWarnable)
}
}
+111 -882
View File
File diff suppressed because it is too large. Load diff
-45
View File
@@ -1,45 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build js || ts_omit_acme
package ipnlocal
import (
"context"
"errors"
"io"
"net/http"
"time"
)
func init() {
RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatusDisabled)
}
var errNoCerts = errors.New("cert support not compiled in this build")
type TLSCertKeyPair struct {
CertPEM, KeyPEM []byte
}
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
return nil, errNoCerts
}
var errCertExpired = errors.New("cert expired")
type certStore interface{}
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
return nil, errNoCerts
}
func (b *LocalBackend) getCertStore() (certStore, error) {
return nil, errNoCerts
}
func handleC2NTLSCertStatusDisabled(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"Missing":true}`) // a minimal tailcfg.C2NTLSCertInfo
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"errors"
"fmt"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn/ipnlocal/netmapcache"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
// diskCache is the state netmap caching to disk.
type diskCache struct {
// all fields guarded by LocalBackend.mu
dir string // active profile cache directory
cache *netmapcache.Cache
}
// writePeerDeltaToDiskLocked applies the specified peer delta to the cache,
// leaving other existing cached fields (self, profiles, other peers not
// mentioned in the arguments) unchanged. It does nothing (without error) if
// both slices are empty.
func (b *LocalBackend) writePeerDeltaToDiskLocked(update []tailcfg.NodeView, remove []tailcfg.StableNodeID) error {
if !buildfeatures.HasCacheNetMap || (len(update) == 0 && len(remove) == 0) {
return nil
} else if err := b.ensureDiskCacheLocked(); err != nil {
return err
}
// Do not log this, we get deltas all the time and it's very noisy.
return b.diskCache.cache.UpdatePeers(b.currentNode().Context(), update, remove)
}
// writeNetmapToDiskLockedWithoutPeers updates nm in the cache, excluding peers and profiles.
func (b *LocalBackend) writeNetmapToDiskLockedWithoutPeers(nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
} else if err := b.ensureDiskCacheLocked(); err != nil {
return err
}
b.logf("updating netmap in disk cache")
return b.diskCache.cache.UpdateSelfOnly(b.currentNode().Context(), b.patchNetmapHomeDERPLocked(nm))
}
// writeNetmapToDiskLockedWithPeers writes nm into the cache, including peers and profiles.
func (b *LocalBackend) writeNetmapToDiskLockedWithPeers(nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
} else if err := b.ensureDiskCacheLocked(); err != nil {
return err
}
b.logf("writing netmap to disk cache")
return b.diskCache.cache.Store(b.currentNode().Context(), b.patchNetmapHomeDERPLocked(nm))
}
func (b *LocalBackend) patchNetmapHomeDERPLocked(nm *netmap.NetworkMap) *netmap.NetworkMap {
// Set the homeDERP on the self node before saving. The self node homeDERP is
// generally not used since the homeDERP for self is stored in magicsock, but
// to be able to load it during loading the cache, we use the existing field
// to save it.
// Make a shallow copy and mutate a copy of the selfNode.
nmCopy := *nm
selfNode := nm.SelfNode.AsStruct()
selfNode.HomeDERP = tailcfg.DERPRegionID(b.currentNode().homeDERP.Load())
nmCopy.SelfNode = selfNode.View()
return &nmCopy
}
func (b *LocalBackend) ensureDiskCacheLocked() error {
dir, err := b.profileMkdirAllLocked(b.pm.CurrentProfile().ID(), "netmap-cache")
if err != nil {
return err
}
if c := b.diskCache; c.cache == nil || c.dir != dir {
b.diskCache.cache = netmapcache.NewCache(netmapcache.FileStore(dir))
b.diskCache.dir = dir
}
return nil
}
func (b *LocalBackend) loadDiskCacheLocked() (om *netmap.NetworkMap, ok bool) {
if !buildfeatures.HasCacheNetMap {
return nil, false
}
dir, err := b.profileMkdirAllLocked(b.pm.CurrentProfile().ID(), "netmap-cache")
if err != nil {
b.logf("profile data directory: %v", err)
return nil, false
}
if c := b.diskCache; c.cache == nil || c.dir != dir {
b.diskCache.cache = netmapcache.NewCache(netmapcache.FileStore(dir))
b.diskCache.dir = dir
}
nm, err := b.diskCache.cache.Load(b.currentNode().Context())
if err != nil {
b.logf("load netmap from cache: %v", err)
return nil, false
}
return nm, true
}
// discardDiskCacheLocked removes a cached network map for the current node, if
// one exists, and disables the cache.
func (b *LocalBackend) discardDiskCacheLocked() {
if !buildfeatures.HasCacheNetMap {
return
}
if b.diskCache.cache == nil {
return // nothing to do, we do not have a cache
}
// Reaching here, we have a cache directory that needs to be purged.
// Log errors but do not fail for them.
store := netmapcache.FileStore(b.diskCache.dir)
if err := b.clearStoreLocked(b.currentNode().Context(), store); err != nil {
b.logf("clearing netmap cache: %v", err)
}
b.diskCache = diskCache{} // drop in-memory state
}
// clearStoreLocked discards all the keys in the specified store.
func (b *LocalBackend) clearStoreLocked(ctx context.Context, store netmapcache.Store) error {
var errs []error
for key, err := range store.List(ctx, "") {
if err != nil {
errs = append(errs, fmt.Errorf("list cache contest: %w", err))
break
}
if err := store.Remove(ctx, key); err != nil {
errs = append(errs, fmt.Errorf("discard cache key %q: %w", key, err))
}
}
return errors.Join(errs...)
}
// ClearNetmapCache discards stored netmap caches (if any) for profiles for the
// current user of b. It also drops any cache from the active backend session,
// if there is one.
func (b *LocalBackend) ClearNetmapCache(ctx context.Context) error {
if !buildfeatures.HasCacheNetMap {
return nil // disabled
}
b.mu.Lock()
defer b.mu.Unlock()
var errs []error
for _, p := range b.pm.Profiles() {
store := netmapcache.FileStore(b.profileDataPathLocked(p.ID(), "netmap-cache"))
err := b.clearStoreLocked(ctx, store)
if err != nil {
errs = append(errs, fmt.Errorf("clear netmap cache for profile %q: %w", p.ID(), err))
}
}
b.diskCache = diskCache{} // drop in-memory state
return errors.Join(errs...)
}
+107 -72
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"net/http"
"net/netip"
"os"
@@ -16,22 +17,43 @@ import (
"tailscale.com/drive"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
)
func init() {
hookSetNetMapLockedDrive.Set(setNetMapLockedDrive)
hookInstallDriveRemoteSource.Set(installDriveRemoteSource)
}
func setNetMapLockedDrive(b *LocalBackend, nm *netmap.NetworkMap) {
b.updateDrivePeersLocked(nm)
// setNetMapLockedDrive runs on every full netmap install (the only path that
// can flip self caps or change the tailnet domain) to re-notify IPN bus
// listeners of the current local shares.
//
// It deliberately does NOT touch the remotes list passed to the local drive
// filesystem: that flows through [driveRemoteSource], which the filesystem
// pulls from lazily and only when something might have changed. See
// [LocalBackend.driveGen].
func setNetMapLockedDrive(b *LocalBackend) {
b.driveNotifyCurrentSharesLocked()
}
// installDriveRemoteSource registers a [drive.RemoteSource] on the local
// Taildrive filesystem so it can pull the current set of remotes on demand
// instead of being pushed a fresh list on every netmap update.
//
// It is wired from [NewLocalBackend] via [hookInstallDriveRemoteSource] so
// non-drive builds don't reference the drive package at all.
func installDriveRemoteSource(b *LocalBackend) {
fs, ok := b.sys.DriveForLocal.GetOK()
if !ok {
return
}
fs.SetRemoteSource(driveRemoteSource{b})
}
// DriveSetServerAddr tells Taildrive to use the given address for connecting
// to the drive.FileServer that's exposing local files as an unprivileged
// user.
@@ -285,80 +307,93 @@ func (b *LocalBackend) DriveGetShares() views.SliceView[*drive.Share, drive.Shar
return b.pm.prefs.DriveShares()
}
// updateDrivePeersLocked sets all applicable peers from the netmap as Taildrive
// remotes.
func (b *LocalBackend) updateDrivePeersLocked(nm *netmap.NetworkMap) {
fs, ok := b.sys.DriveForLocal.GetOK()
if !ok {
return
}
// driveRemoteSource implements [drive.RemoteSource] by reading from a
// [LocalBackend]. It is installed once on the local Taildrive filesystem
// at [NewLocalBackend] time and consulted lazily on incoming WebDAV
// requests.
//
// The filesystem only rebuilds its internal child list when [Generation]
// changes, so peer churn that doesn't affect the drive-capable peer set
// (e.g. endpoint or online-status mutations on non-drive peers) costs
// nothing more than a single atomic load.
type driveRemoteSource struct{ b *LocalBackend }
var driveRemotes []*drive.Remote
if b.DriveAccessEnabled() {
// Only populate peers if access is enabled, otherwise leave blank.
driveRemotes = b.driveRemotesFromPeers(nm)
}
fs.SetRemotes(nm.Domain, driveRemotes, b.newDriveTransport())
// Generation implements [drive.RemoteSource].
func (s driveRemoteSource) Generation() uint64 {
return s.b.driveGen.Load()
}
func (b *LocalBackend) driveRemotesFromPeers(nm *netmap.NetworkMap) []*drive.Remote {
b.logf("[v1] taildrive: setting up drive remotes from peers")
driveRemotes := make([]*drive.Remote, 0, len(nm.Peers))
for _, p := range nm.Peers {
peer := p
peerID := peer.ID()
peerKey := peer.Key().ShortString()
b.logf("[v1] taildrive: appending remote for peer %s", peerKey)
driveRemotes = append(driveRemotes, &drive.Remote{
Name: p.DisplayName(false),
URL: func() string {
url := fmt.Sprintf("%s/%s", b.currentNode().PeerAPIBase(peer), taildrivePrefix[1:])
b.logf("[v2] taildrive: url for peer %s: %s", peerKey, url)
return url
},
Available: func() bool {
// Peers are available to Taildrive if:
// - They are online
// - Their PeerAPI is reachable
// - They are allowed to share at least one folder with us
cn := b.currentNode()
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: Available(): peer %s not found", peerKey)
return false
}
// Domain implements [drive.RemoteSource].
func (s driveRemoteSource) Domain() string {
nm := s.b.currentNode().NetMap()
if nm == nil {
return ""
}
return nm.Domain
}
// Exclude offline peers.
// TODO(oxtoacart): for some reason, this correctly
// catches when a node goes from offline to online,
// but not the other way around...
// TODO(oxtoacart,nickkhyl): the reason was probably
// that we were using netmap.Peers instead of b.peers.
// The netmap.Peers slice is not updated in all cases.
// It should be fixed now that we use PeerByIDOk.
if !peer.Online().Get() {
b.logf("[v2] taildrive: Available(): peer %s offline", peerKey)
return false
}
// Transport implements [drive.RemoteSource].
func (s driveRemoteSource) Transport() http.RoundTripper {
return s.b.newDriveTransport()
}
if b.currentNode().PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: Available(): peer %s PeerAPI unreachable", peerKey)
// Remotes implements [drive.RemoteSource]. It yields one [*drive.Remote]
// per peer; the per-Remote Available closure filters out peers that
// aren't online, don't expose PeerAPI, or haven't granted us the
// Taildrive sharer cap. If drive access is disabled on this node, no
// remotes are yielded at all, so the filesystem ends up with an empty
// child list.
func (s driveRemoteSource) Remotes() iter.Seq[*drive.Remote] {
return func(yield func(*drive.Remote) bool) {
b := s.b
if !b.DriveAccessEnabled() {
return
}
cn := b.currentNode()
peers := cn.Peers()
b.logf("[v1] taildrive: building drive remotes from %d peers", len(peers))
for _, peer := range peers {
peerID := peer.ID()
peerKey := peer.Key().ShortString()
peerName := peer.DisplayName(false)
r := &drive.Remote{
Name: peerName,
URL: func() string {
url := fmt.Sprintf("%s/%s", cn.PeerAPIBase(peer), taildrivePrefix[1:])
b.logf("[v2] taildrive: url for peer %s (%s): %s", peerKey, peerName, url)
return url
},
Available: func() bool {
// Peers are available to Taildrive if:
// - They are online
// - Their PeerAPI is reachable
// - They are allowed to share at least one folder with us
peer, ok := cn.NodeByID(peerID)
if !ok {
b.logf("[v2] taildrive: peer %s (%s, id=%v) not found", peerKey, peerName, peerID)
return false
}
if !peer.Online().Get() {
b.logf("[v2] taildrive: peer %s (%s, id=%v) offline", peerKey, peerName, peerID)
return false
}
if cn.PeerAPIBase(peer) == "" {
b.logf("[v2] taildrive: peer %s (%s, id=%v) PeerAPI unreachable", peerKey, peerName, peerID)
return false
}
if cn.PeerHasCap(peer, peercap.TaildriveSharer) {
b.logf("[v2] taildrive: peer %s (%s, id=%v) available", peerKey, peerName, peerID)
return true
}
b.logf("[v2] taildrive: peer %s (%s, id=%v) not allowed to share", peerKey, peerName, peerID)
return false
}
// Check that the peer is allowed to share with us.
if cn.PeerHasCap(peer, tailcfg.PeerCapabilityTaildriveSharer) {
b.logf("[v2] taildrive: Available(): peer %s available", peerKey)
return true
}
b.logf("[v2] taildrive: Available(): peer %s not allowed to share", peerKey)
return false
},
})
},
}
if !yield(r) {
return
}
}
}
return driveRemotes
}
// responseBodyWrapper wraps an io.ReadCloser and stores
+4 -4
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// This is the Taildrive stuff that should ideally be registered in init only when
@@ -7,7 +7,7 @@
package ipnlocal
import "tailscale.com/tailcfg"
import "tailscale.com/tailcfg/nodecap"
const (
// DriveLocalPort is the port on which the Taildrive listens for location
@@ -19,12 +19,12 @@ const (
// enabled. This is currently based on checking for the drive:share node
// attribute.
func (b *LocalBackend) DriveSharingEnabled() bool {
return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveShare)
return b.currentNode().SelfHasCap(nodecap.TaildriveShare)
}
// DriveAccessEnabled reports whether accessing Taildrive shares on remote nodes
// is enabled. This is currently based on checking for the drive:access node
// attribute.
func (b *LocalBackend) DriveAccessEnabled() bool {
return b.currentNode().SelfHasCap(tailcfg.NodeAttrsTaildriveAccess)
return b.currentNode().SelfHasCap(nodecap.TaildriveAccess)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
+29 -4
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"strings"
@@ -90,6 +89,11 @@ type ExtensionHost struct {
extByType sync.Map // reflect.Type -> ipnext.Extension
// hasPendingAuthReconfig tracks whether an AuthReconfig call
// has been enqueued but not yet executed. It avoids redundant
// reconfigs and prevents them from piling up in the workQueue.
hasPendingAuthReconfig atomic.Bool
// mu protects the following fields.
// It must not be held when calling [LocalBackend] methods
// or when invoking callbacks registered by extensions.
@@ -124,6 +128,8 @@ type Backend interface {
NodeBackend() ipnext.NodeBackend
authReconfig()
ipnext.SafeBackend
}
@@ -262,7 +268,10 @@ func (h *ExtensionHost) init() {
// Report active extensions to the log.
// TODO(nickkhyl): update client metrics to include the active/failed/skipped extensions.
h.mu.Lock()
extensionNames := slices.Collect(maps.Keys(h.extensionsByName))
var extensionNames []string
for _, ext := range h.activeExtensions {
extensionNames = append(extensionNames, ext.Name())
}
h.mu.Unlock()
h.logf("active extensions: %v", strings.Join(extensionNames, ", "))
@@ -339,7 +348,7 @@ func (h *ExtensionHost) FindMatchingExtension(target any) bool {
val := reflect.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflect.Ptr || val.IsNil() {
if typ.Kind() != reflect.Pointer || val.IsNil() {
panic("ipnext: target must be a non-nil pointer")
}
targetType := typ.Elem()
@@ -541,6 +550,22 @@ func (h *ExtensionHost) Shutdown() {
h.shutdownOnce.Do(h.shutdown)
}
// AuthReconfigAsync implements [ipnext.Host.AuthReconfigAsync].
// Since execution uses the most recent state at execution time,
// multiple enqueued calls are redundant and are not enqueued.
func (h *ExtensionHost) AuthReconfigAsync() {
if h == nil {
return
}
if h.hasPendingAuthReconfig.Swap(true) {
return
}
h.enqueueBackendOperation(func(b Backend) {
h.hasPendingAuthReconfig.Store(false)
b.authReconfig()
})
}
func (h *ExtensionHost) shutdown() {
h.shuttingDown.Store(true)
// Prevent any queued but not yet started operations from running,
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"crypto/tls"
"net/http"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/util/testenv"
"tailscale.com/wgengine/filter"
)
// forTest is an unexported type to hide all the test-only
// methods on [LocalBackend] from godoc.
type forTest struct{ b *LocalBackend }
// ForTest returns a handle to test-only methods on b.
// The resulting type is unexported to make it very obvious
// in godoc that this is not stable API. This method panics
// if called outside of tests, which also centralizes all
// must-be-in-tests validation.
func (b *LocalBackend) ForTest() forTest {
testenv.AssertInTest()
return forTest{b}
}
// HandleC2N calls [LocalBackend.handleC2N], for use by feature/ packages that
// register C2N handlers and want to test them.
func (f forTest) HandleC2N(w http.ResponseWriter, r *http.Request) {
f.b.handleC2N(w, r)
}
// SetIPServiceMappings overwrites the LocalBackend's IP-to-service mappings
// and propagates them to the netstack subsystem if registered.
func (f forTest) SetIPServiceMappings(m netmap.IPServiceMappings) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.ipVIPServiceMap = m
if ns, ok := b.sys.Netstack.GetOK(); ok {
ns.UpdateIPServiceMappings(m)
}
}
// GetFilter returns the current packet filter.
func (f forTest) GetFilter() *filter.Filter {
b := f.b
// Take b.mu so the read serializes with [LocalBackend.setControlClientStatusLocked],
// which installs the netmap and the filter at separate sub-steps. Without
// this, a test thread that observes the new netmap (via [LocalBackend.NetMapWithPeers])
// can race ahead of the filter store and read the previous filter.
b.mu.Lock()
defer b.mu.Unlock()
return b.currentNode().filterAtomic.Load()
}
// SetControlClientGetter sets the func that creates a control plane
// client. It can be called at most once, before Start.
func (f forTest) SetControlClientGetter(newControlClient func(controlclient.Options) (controlclient.Client, error)) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
if b.ccGen != nil {
panic("invalid use of forTest.SetControlClientGetter after Start")
}
b.ccGen = newControlClient
}
// Peers returns all the current peers, sorted by Node.ID, for integration
// tests in another repo.
func (f forTest) Peers() []tailcfg.NodeView {
return f.b.currentNode().PeersForTest()
}
// AwaitNodeKey returns a channel that is closed once a peer with the given
// node key first appears in the current netmap. If the peer is already
// present, the returned channel is already closed. See
// [nodeBackend.AwaitNodeKeyForTest].
func (f forTest) AwaitNodeKey(k key.NodePublic) <-chan struct{} {
return f.b.currentNode().AwaitNodeKeyForTest(k)
}
// CurrentUser returns the current user and the associated WindowsUserID.
// It will be removed along with the rest of the "current user" functionality
// as we progress on the multi-user improvements (tailscale/corp#18342).
func (f forTest) CurrentUser() (ipn.WindowsUserID, ipnauth.Actor) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
return b.pm.CurrentUserID(), b.currentUser
}
// ConfigureCerts sets a certificate retrieval function to be used by this
// local backend, skipping the usual ACME certificate registration.
func (f forTest) ConfigureCerts(getCert func(hostname string) (*TLSCertKeyPair, error)) {
hook, ok := HookConfigureCertsForTest.GetOk()
if !ok {
panic("forTest.ConfigureCerts called without cert extension registered")
}
hook(f.b, getCert)
}
// GetACMETLSALPNCert returns the short-lived ACME tls-alpn-01 challenge
// certificate for hi, if any.
func (f forTest) GetACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
return f.b.getACMETLSALPNCert(hi)
}
// SetServeConfig installs sc as the backend's current
// [ipn.ServeConfig] without going through the validation in
// [LocalBackend.SetServeConfig]. It is intended for tests that need a
// specific serve config without first standing up the prerequisites
// (netmap, prefs, etc.).
func (f forTest) SetServeConfig(sc ipn.ServeConfigView) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.serveConfig = sc
}
// SetNetMap installs nm as the backend's current netmap without going
// through control-plane plumbing. It is intended for tests that need a
// specific netmap (e.g. CertDomains, capabilities).
func (f forTest) SetNetMap(nm *netmap.NetworkMap) {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.currentNode().SetNetMap(nm)
}
// SetClock replaces b's clock with c, for tests that need
// time-dependent behavior to be deterministic.
func (f forTest) SetClock(c tstime.Clock) { f.b.clock = c }
// SetPrefs replaces the current prefs with newp.
func (f forTest) SetPrefs(newp *ipn.Prefs) {
if newp == nil {
panic("forTest.SetPrefs got nil prefs")
}
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
b.setPrefsLocked(newp)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tpm
+1989 -541
View File
File diff suppressed because it is too large. Load diff
+452
View File
@@ -0,0 +1,452 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package netmapcache implements a persistent cache for [netmap.NetworkMap]
// values, allowing a client to start up using stale but previously-valid state
// even if a connection to the control plane is not immediately available.
package netmapcache
import (
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
jsonv1 "encoding/json"
"errors"
"fmt"
"io/fs"
"iter"
"os"
"path/filepath"
"slices"
"strings"
"time"
"tailscale.com/feature/buildfeatures"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/netmap"
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/wgengine/filter"
)
var (
// ErrKeyNotFound is a sentinel error reported by implementations of the [Store]
// interface when loading a key that is not found in the store.
ErrKeyNotFound = errors.New("storage key not found")
// ErrCacheNotAvailable is a sentinel error reported by cache methods when
// the netmap caching feature is not enabled in the build.
ErrCacheNotAvailable = errors.New("netmap cache is not available")
)
// A Cache manages a columnar cache of a [netmap.NetworkMap]. Each Cache holds
// a single netmap value; use [Cache.Store] to update or replace the cached
// value and [Cache.Load] to read the cached value.
type Cache struct {
store Store
// wantKeys records the cache keys from the last write or load of a cached
// netmap. This is used to prune keys that are no longer referenced after an
// update.
wantKeys set.Set[cacheKey]
// lastWrote records the last values written to each stored key.
//
// TODO(creachadair): This is meant to avoid disk writes, but I'm not
// convinced we need it. Or maybe just track hashes of the content rather
// than caching a complete copy.
lastWrote map[cacheKey]lastWrote
}
// NewCache constructs a new empty [Cache] from the given [Store].
// It will panic if s == nil.
func NewCache(s Store) *Cache {
if s == nil {
panic("a non-nil Store is required")
}
return &Cache{
store: s,
wantKeys: make(set.Set[cacheKey]),
lastWrote: make(map[cacheKey]lastWrote),
}
}
type lastWrote struct {
digest string
at time.Time
}
func (c *Cache) writeJSON(ctx context.Context, key cacheKey, v any) error {
j, err := jsonv1.Marshal(v)
if err != nil {
return fmt.Errorf("JSON marshalling %q: %w", key, err)
}
// If the digest of this value has not changed since it was last written,
// record that the key is of interest, but do not actually perform a write.
last, ok := c.lastWrote[key]
if ok && cacheDigest(j) == last.digest {
c.wantKeys.Add(key)
return nil
}
if err := c.store.Store(ctx, string(key), j); err != nil {
return err
}
// Track the storage keys the current map is using, for storage GC.
c.wantKeys.Add(key)
c.lastWrote[key] = lastWrote{
digest: cacheDigest(j),
at: time.Now(),
}
return nil
}
func (c *Cache) removeUnwantedKeys(ctx context.Context) error {
var errs []error
for key, err := range c.store.List(ctx, "") {
if err != nil {
errs = append(errs, err)
break
}
ckey := cacheKey(key)
if !c.wantKeys.Contains(ckey) {
if err := c.store.Remove(ctx, key); err != nil {
errs = append(errs, fmt.Errorf("remove key %q: %w", key, err))
}
delete(c.lastWrote, ckey) // even if removal failed, we don't want it
}
}
return errors.Join(errs...)
}
// FileStore implements the [Store] interface using a directory of files, in
// which each key is encoded as a filename in the directory.
// The caller is responsible to ensure the directory path exists before
// using the store methods.
type FileStore string
// List implements part of the [Store] interface.
func (s FileStore) List(ctx context.Context, prefix string) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
des, err := os.ReadDir(string(s))
if os.IsNotExist(err) {
return // nothing to read
} else if err != nil {
yield("", err)
return
}
// os.ReadDir reports entries already sorted, and the encoding preserves that.
for _, de := range des {
key, err := hex.DecodeString(de.Name())
if err != nil {
yield("", err)
return
}
name := string(key)
if !strings.HasPrefix(name, prefix) {
continue
} else if !yield(name, nil) {
return
}
}
}
}
// Load implements part of the [Store] interface.
func (s FileStore) Load(ctx context.Context, key string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("key %q not found: %w", key, ErrKeyNotFound)
}
return data, err
}
// Store implements part of the [Store] interface.
func (s FileStore) Store(ctx context.Context, key string, value []byte) error {
return os.WriteFile(filepath.Join(string(s), hex.EncodeToString([]byte(key))), value, 0600)
}
// Remove implements part of the [Store] interface.
func (s FileStore) Remove(ctx context.Context, key string) error {
err := os.Remove(filepath.Join(string(s), hex.EncodeToString([]byte(key))))
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
// cacheKey is a type wrapper for strings used as cache keys.
type cacheKey string
const (
selfKey cacheKey = "self"
miscKey cacheKey = "msic"
dnsKey cacheKey = "dns"
derpMapKey cacheKey = "derpmap"
peerKeyPrefix cacheKey = "peer-" // + stable ID
userKeyPrefix cacheKey = "user-" // + profile ID
sshPolicyKey cacheKey = "ssh"
packetFilterKey cacheKey = "filter"
)
// UpdateSelfOnly updates nm in the cache, replacing any previously cached
// values for nm.Self and other tailnet metadata for the current node, but
// skipping the Peers and UserProfiles fields. Any existing peer and profile
// data are left unmodified.
func (c *Cache) UpdateSelfOnly(ctx context.Context, nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
}
if selfID := nm.User(); selfID == 0 {
return errors.New("no user in netmap")
}
// Since we are not modifying peers or users (and in particular, not
// removing any), we do not need to do any garbage collection on the storage
// keys.
return c.updateSelfOnly(ctx, nm)
}
// Store records nm in the cache, replacing any previously-cached values.
func (c *Cache) Store(ctx context.Context, nm *netmap.NetworkMap) error {
if !buildfeatures.HasCacheNetMap || nm == nil || nm.Cached {
return nil
}
if selfID := nm.User(); selfID == 0 {
return errors.New("no user in netmap")
}
// Because we may modify which peers and user profiles are valid, we need to
// keep track of which storage keys we actually touch during the store.
// This is used by c.removeUnwantedKeys to clean up keys that are no longer
// referenced after peers and/or profiles are removed from nm.
clear(c.wantKeys)
if err := c.updateSelfOnly(ctx, nm); err != nil {
return err
}
for _, p := range nm.Peers {
key := peerKeyPrefix + cacheKey(p.StableID())
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
return err
}
}
for uid, u := range nm.UserProfiles {
key := fmt.Sprintf("%s%d", userKeyPrefix, uid)
if err := c.writeJSON(ctx, cacheKey(key), netmapUserProfile{UserProfile: &u}); err != nil {
return err
}
}
return c.removeUnwantedKeys(ctx)
}
// UpdatePeers updates the cache to add or replace ("upsert") any peers
// specified in update, and discard any peers specified in remove. This does
// not affect any previous cached values for the self node or user profiles.
//
// It is expected that update and remove will be disjoint, but in the event
// they are not, remove takes precedence. If the cache does not already contain
// at least a self node, UpdatePeers reports an error without changing anything.
func (c *Cache) UpdatePeers(ctx context.Context, update []tailcfg.NodeView, remove []tailcfg.StableNodeID) error {
if !buildfeatures.HasCacheNetMap {
return nil
}
if !c.wantKeys.Contains(selfKey) {
return errors.New("no netmap is cached to apply an update")
}
// Attempt all updates and removals before reporting any errors.
var errs []error
for _, p := range update {
// We may already have an entry for this peer, or it may be new, but
// rather than do a lookup we can just replace the existing entry if it
// exists.
key := peerKeyPrefix + cacheKey(p.StableID())
if err := c.writeJSON(ctx, key, netmapNode{Node: &p}); err != nil {
errs = append(errs, err)
continue
}
c.wantKeys.Add(key)
}
for _, q := range remove {
key := peerKeyPrefix + cacheKey(q)
if err := c.store.Remove(ctx, string(key)); err != nil {
errs = append(errs, err)
}
c.wantKeys.Delete(key)
delete(c.lastWrote, key)
}
return errors.Join(errs...)
}
// updateSelfOnly updates the "static" parts of the netmap in the cache. It is
// shared between [Cache.UpdateSelfOnly] and [Cache.Store].
func (c *Cache) updateSelfOnly(ctx context.Context, nm *netmap.NetworkMap) error {
if err := c.writeJSON(ctx, miscKey, netmapMisc{
MachineKey: &nm.MachineKey,
CollectServices: &nm.CollectServices,
DisplayMessages: &nm.DisplayMessages,
TKAEnabled: &nm.TKAEnabled,
TKAHead: &nm.TKAHead,
Domain: &nm.Domain,
DomainAuditLogID: &nm.DomainAuditLogID,
}); err != nil {
return err
}
if err := c.writeJSON(ctx, dnsKey, netmapDNS{DNS: &nm.DNS}); err != nil {
return err
}
if err := c.writeJSON(ctx, derpMapKey, netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
return err
}
if err := c.writeJSON(ctx, selfKey, netmapNode{Node: &nm.SelfNode}); err != nil {
return err
// N.B. The NodeKey and AllCaps fields can be recovered from SelfNode on
// load, and do not need to be stored separately.
}
if err := c.writeJSON(ctx, packetFilterKey, netmapPacketFilter{Rules: &nm.PacketFilterRules}); err != nil {
return err
}
if buildfeatures.HasSSH && nm.SSHPolicy != nil {
if err := c.writeJSON(ctx, sshPolicyKey, netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
return err
}
}
return nil
}
// Load loads the cached [netmap.NetworkMap] value stored in c, if one is available.
// It reports [ErrCacheNotAvailable] if no cached data are available.
// On success, the Cached field of the returned network map is true.
func (c *Cache) Load(ctx context.Context) (*netmap.NetworkMap, error) {
if !buildfeatures.HasCacheNetMap {
return nil, ErrCacheNotAvailable
}
nm := netmap.NetworkMap{Cached: true}
// At minimum, we require that the cache contain a "self" node, or the data
// are not usable.
if self, err := c.store.Load(ctx, string(selfKey)); errors.Is(err, ErrKeyNotFound) {
return nil, ErrCacheNotAvailable
} else if err := jsonv1.Unmarshal(self, &netmapNode{Node: &nm.SelfNode}); err != nil {
return nil, err
}
c.wantKeys.Add(selfKey)
// If we successfully recovered a SelfNode, pull out its related fields.
if s := nm.SelfNode; s.Valid() {
nm.NodeKey = s.Key()
nm.AllCaps = make(set.Set[nodecap.Cap])
for _, c := range s.Capabilities().All() {
nm.AllCaps.Add(c)
}
for c := range s.CapMap().All() {
nm.AllCaps.Add(c)
}
}
// Unmarshal the contents of each specified cache entry directly into the
// fields of the output. See the comment in types.go for more detail.
if err := c.readJSON(ctx, miscKey, &netmapMisc{
MachineKey: &nm.MachineKey,
CollectServices: &nm.CollectServices,
DisplayMessages: &nm.DisplayMessages,
TKAEnabled: &nm.TKAEnabled,
TKAHead: &nm.TKAHead,
Domain: &nm.Domain,
DomainAuditLogID: &nm.DomainAuditLogID,
}); err != nil {
return nil, err
}
if err := c.readJSON(ctx, dnsKey, &netmapDNS{DNS: &nm.DNS}); err != nil {
return nil, err
}
if err := c.readJSON(ctx, derpMapKey, &netmapDERPMap{DERPMap: &nm.DERPMap}); err != nil {
return nil, err
}
for key, err := range c.store.List(ctx, string(peerKeyPrefix)) {
if err != nil {
return nil, err
}
var peer tailcfg.NodeView
if err := c.readJSON(ctx, cacheKey(key), &netmapNode{Node: &peer}); err != nil {
return nil, err
}
nm.Peers = append(nm.Peers, peer)
}
slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int { return cmp.Compare(a.ID(), b.ID()) })
for key, err := range c.store.List(ctx, string(userKeyPrefix)) {
if err != nil {
return nil, err
}
var up tailcfg.UserProfileView
if err := c.readJSON(ctx, cacheKey(key), &netmapUserProfile{UserProfile: &up}); err != nil {
return nil, err
}
mak.Set(&nm.UserProfiles, up.ID(), up)
}
if err := c.readJSON(ctx, sshPolicyKey, &netmapSSH{SSHPolicy: &nm.SSHPolicy}); err != nil {
return nil, err
}
if err := c.readJSON(ctx, packetFilterKey, &netmapPacketFilter{Rules: &nm.PacketFilterRules}); err != nil {
return nil, err
} else if r := nm.PacketFilterRules; r.Len() != 0 {
// Reconstitute packet match expressions from the filter rules,
nm.PacketFilter, err = filter.MatchesFromFilterRules(r.AsSlice())
if err != nil {
return nil, err
}
}
return &nm, nil
}
func (c *Cache) readJSON(ctx context.Context, key cacheKey, value any) error {
data, err := c.store.Load(ctx, string(key))
if errors.Is(err, ErrKeyNotFound) {
return nil
} else if err != nil {
return err
}
if err := jsonv1.Unmarshal(data, value); err != nil {
return err
}
c.wantKeys.Add(key)
c.lastWrote[key] = lastWrote{digest: cacheDigest(data), at: time.Now()}
return nil
}
// Store is the interface to persistent key-value storage used by a [Cache].
type Store interface {
// List lists all the stored keys having the specified prefixes, in
// lexicographic order.
//
// Each pair yielded by the iterator is either a valid storage key and a nil
// error, or an empty key and a non-nil error. After reporting an error, the
// iterator must immediately return.
List(ctx context.Context, prefix string) iter.Seq2[string, error]
// Load fetches the contents of the specified key.
// If the key is not found in the store, Load must report [ErrKeyNotFound].
Load(ctx context.Context, key string) ([]byte, error)
// Store marshals and stores the contents of the specified value under key.
// If the key already exists, its contents are replaced.
Store(ctx context.Context, key string, value []byte) error
// Remove removes the specified key from the store. If the key does not exist,
// Remove reports success (nil).
Remove(ctx context.Context, key string) error
}
// cacheDigest computes a string digest of the specified data, for use in
// detecting cache hits.
func cacheDigest(data []byte) string { h := sha256.Sum256(data); return string(h[:]) }
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package netmapcache
import (
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
// The fields in the following wrapper types are all pointers, even when their
// target type is also a pointer, so that they can be used to unmarshal
// directly into the fields of another value. These wrappers intentionally do
// not omit zero or empty values, since we want the cache to reflect the value
// the object had at the time it was written, even if the default changes
// later.
//
// Moreover, these are all struct types so that each cached record will be a
// JSON object even if the underlying value marshals to an array or primitive
// type, and so that we have a seam if we want to replace or version the cached
// representation separately from the default JSON layout.
type netmapMisc struct {
MachineKey *key.MachinePublic
CollectServices *bool
DisplayMessages *map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
TKAEnabled *bool
TKAHead *tka.AUMHash
Domain *string
DomainAuditLogID *string
}
type netmapSSH struct {
SSHPolicy **tailcfg.SSHPolicy
}
type netmapDNS struct {
DNS *tailcfg.DNSConfig
}
type netmapDERPMap struct {
DERPMap **tailcfg.DERPMap
}
type netmapNode struct {
Node *tailcfg.NodeView
}
type netmapUserProfile struct {
UserProfile *tailcfg.UserProfileView
}
type netmapPacketFilter struct {
Rules *views.Slice[tailcfg.FilterRule]
// Match expressions are derived from the rules.
}
+2 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_netstack
@@ -11,7 +11,6 @@ import (
"time"
"gvisor.dev/gvisor/pkg/tcpip"
"tailscale.com/types/ptr"
)
// TCPHandlerForDst returns a TCP handler for connections to dst, or nil if
@@ -52,7 +51,7 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
// tell the difference between a long lived connection that is idle
// vs a connection that is dead because the peer has gone away.
// We pick 72h as that is typically sufficient for a long weekend.
opts = append(opts, ptr.To(tcpip.KeepaliveIdleOption(72*time.Hour)))
opts = append(opts, new(tcpip.KeepaliveIdleOption(72*time.Hour)))
return b.handleSSHConn, opts
}
// TODO(will,sonia): allow customizing web client port ?
+996 -188
View File
File diff suppressed because it is too large. Load diff
+190 -54
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -35,14 +35,21 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/netmap"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/testenv"
"tailscale.com/wgengine/filter"
)
// initListenConfig, if non-nil, is called during peerAPIListener setup. It is used only
// on iOS and macOS to set socket options to bind the listener to the Tailscale interface.
// initListenConfig, if non-nil, is called during peerAPIListener setup.
// On iOS and macOS, it sets socket options to bind the listener to the
// Tailscale interface. On Linux, it binds the listener to the tunnel
// interface, since peerapi traffic from other nodes is served by
// netstack in userspace and the kernel listener only needs to serve
// the local host.
var initListenConfig func(config *net.ListenConfig, addr netip.Addr, tunIfIndex int) error
// peerDNSQueryHandler is implemented by tsdns.Resolver.
@@ -55,14 +62,43 @@ type peerAPIServer struct {
resolver peerDNSQueryHandler
}
// skipKernelListener reports whether peerapi should not create a
// kernel-level listener at all and instead rely on netstack intercepting
// connections to the peerapi port. See [fakePeerAPIListener].
func (s *peerAPIServer) skipKernelListener() bool {
switch runtime.GOOS {
case "android":
// Android for whatever reason often has problems creating the
// peerapi listener (Issues 4449, 4293). Since netstack
// intercepts the connections anyway, the kernel listener isn't
// needed.
return true
case "freebsd":
// FreeBSD is a weak-host stack like Linux: a kernel listener on
// the node's Tailscale IP would answer TCP handshakes from
// LAN-adjacent machines that address that IP at the node's NIC
// (tailscale/corp#48248). Linux fixes that with SO_BINDTODEVICE
// and macOS/iOS with IP_BOUND_IF, but FreeBSD has no per-socket
// equivalent, so skip the kernel listener entirely whenever
// netstack is present. Without netstack (ts_omit_netstack),
// the kernel listener is the only way to serve peers, and
// protecting it is left to pf; see the log line in
// [peerAPIServer.listen].
_, hasNetstack := s.b.sys.Netstack.GetOK()
return hasNetstack
}
return false
}
func (s *peerAPIServer) listen(ip netip.Addr, tunIfIndex int) (ln net.Listener, err error) {
// Android for whatever reason often has problems creating the peerapi listener.
// But since we started intercepting it with netstack, it's not even important that
// we have a real kernel-level listener. So just create a dummy listener on Android
// and let netstack intercept it.
if runtime.GOOS == "android" {
if s.skipKernelListener() {
return newFakePeerAPIListener(ip), nil
}
if runtime.GOOS == "freebsd" {
// No netstack, so a kernel listener is required; see
// skipKernelListener for why it is exposed.
s.b.logf("peerapi: no netstack in this build; the kernel listener on %v answers connections from any interface (FreeBSD's weak host model), so restrict it to the tunnel interface with pf", ip)
}
ipStr := ip.String()
@@ -70,13 +106,10 @@ func (s *peerAPIServer) listen(ip netip.Addr, tunIfIndex int) (ln net.Listener,
if initListenConfig != nil {
// On iOS/macOS, this sets the lc.Control hook to
// setsockopt the interface index to bind to, to get
// out of the network sandbox.
// A zero tunIfIndex is invalid for peerapi. A zero value will not get us
// out of the network sandbox. Caller should log and retry.
if tunIfIndex == 0 {
return nil, fmt.Errorf("peerapi: cannot listen on %s with tunIfIndex 0", ipStr)
}
// out of the network sandbox. On Linux, it binds the
// listener to the tunnel interface so the kernel does
// not answer peerapi handshakes from the physical
// network; see initListenConfigTun.
if err := initListenConfig(&lc, ip, tunIfIndex); err != nil {
return nil, err
@@ -103,7 +136,7 @@ func (s *peerAPIServer) listen(ip netip.Addr, tunIfIndex int) (ln net.Listener,
// deterministic that people will bake this into clients.
// We try a few times just in case something's already
// listening on that port (on all interfaces, probably).
for try := uint8(0); try < 5; try++ {
for try := range uint8(5) {
a16 := ip.As16()
hashData := a16[len(a16)-3:]
hashData[0] += try
@@ -192,7 +225,7 @@ func (pln *peerAPIListener) ServeConn(src netip.AddrPort, c net.Conn) {
c.Close()
return
}
nm := pln.lb.NetMap()
nm := pln.lb.NetMapNoPeers()
if nm == nil || !nm.SelfNode.Valid() {
logf("peerapi: no netmap")
c.Close()
@@ -263,10 +296,16 @@ func (h *peerAPIHandler) isAddressValid(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
v4MasqAddr, hasMasqV4 := h.peerNode.SelfNodeV4MasqAddrForThisPeer().GetOk()
v6MasqAddr, hasMasqV6 := h.peerNode.SelfNodeV6MasqAddrForThisPeer().GetOk()
if hasMasqV4 || hasMasqV6 {
return addr == v4MasqAddr || addr == v6MasqAddr
// A masquerade address for a family, if set, replaces this node's
// native address of that family from the peer's point of view.
// A masquerade address for one family says nothing about the other
// family, so the other family still falls through to the self
// address check below.
if v4MasqAddr, ok := h.peerNode.SelfNodeV4MasqAddrForThisPeer().GetOk(); ok && addr.Is4() {
return addr == v4MasqAddr
}
if v6MasqAddr, ok := h.peerNode.SelfNodeV6MasqAddrForThisPeer().GetOk(); ok && addr.Is6() {
return addr == v6MasqAddr
}
pfx := netip.PrefixFrom(addr, addr.BitLen())
return views.SliceContains(h.selfNode.Addresses(), pfx)
@@ -578,7 +617,7 @@ func (h *peerAPIHandler) CanDebug() bool { return h.canDebug() }
// canDebug reports whether h can debug this node (goroutines, metrics,
// magicsock internal state, etc).
func (h *peerAPIHandler) canDebug() bool {
if !h.selfNode.HasCap(tailcfg.CapabilityDebug) {
if !h.selfNode.HasCap(nodecap.Debug) {
// This node does not expose debug info.
return false
}
@@ -586,17 +625,17 @@ func (h *peerAPIHandler) canDebug() bool {
// Unsigned peers can't debug.
return false
}
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityDebugPeer)
return h.isSelf || h.peerHasCap(peercap.DebugPeer)
}
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
// canIngress reports whether h can send ingress requests to this node.
func (h *peerAPIHandler) canIngress() bool {
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
return h.peerHasCap(peercap.Ingress) || (allowSelfIngress() && h.isSelf)
}
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
func (h *peerAPIHandler) peerHasCap(wantCap peercap.Cap) bool {
return h.PeerCaps().HasCapability(wantCap)
}
@@ -674,26 +713,107 @@ func (h *peerAPIHandler) handleServeDNSFwd(w http.ResponseWriter, r *http.Reques
dh.ServeHTTP(w, r)
}
func (h *peerAPIHandler) replyToDNSQueries() bool {
// DNSNameFilter allows extensions to conditionally allow PeerAPI DNS queries
// based on the name being queried, in addition to the source of the query. It
// is used by [HookReplyToDNSQueries].
type DNSNameFilter func(name string) (allowed bool)
// HookReplyToDNSQueries allows extensions to register a willingness to allow
// handling PeerAPI DNS queries for the peer making this request, optionally
// depending on the name being queried. The [http.Request.Body] must not be read
// by the handler.
// When sourceAllowed is false, the query is disallowed and nameAllowed is
// ignored (recommendation: nameAllowed should be nil in this case).
// When sourceAllowed is true, the peer is permitted to send queries but the
// final decision depends on the name being queried. When nameAllowed is nil,
// all names are allowed. Otherwise, nameAllowed is called after parsing the
// query to determine if it should be accepted.
// While separating the decisions complicates this hook, it permits optimizing
// the common case where all names are allowed for exit nodes and appc.
var HookReplyToDNSQueries = feature.Hooks[func(PeerAPIHandler, *http.Request) (sourceAllowed bool, nameAllowed DNSNameFilter)]{
offersExitNodeOrAppConnectorAndPeerHasAutogroupInternet,
}
// isPeerAPIDNSAllowed determines if any of the default or extension hooks
// permit PeerAPI DNS lookups for the current request.
// nameAllowed will never be nil when sourceAllowed is true, as required by
// [tailscale.com/net/dns/resolver.Resolver.HandlePeerDNSQuery], so it is not
// quite the same as a [DNSNameFilter].
func (h *peerAPIHandler) isPeerAPIDNSAllowed(r *http.Request) (sourceAllowed bool, nameAllowed func(string) bool) {
if !buildfeatures.HasDNS {
return false
return false, nil
}
if h.isSelf {
if h.IsSelfUntagged() {
// If the peer is owned by the same user, just allow it
// without further checks.
return true
}
b := h.ps.b
if !b.OfferingExitNode() && !b.OfferingAppConnector() {
// If we're not an exit node or app connector, there's
// no point to being a DNS server for somebody.
return false
return true, h.allowExitNodeDNSProxyToServeName
}
if !h.remoteAddr.IsValid() {
// This should never be the case if the peerAPIHandler
// was wired up correctly, but just in case.
return false, nil
}
nameFilters := make([]DNSNameFilter, 0, len(HookReplyToDNSQueries))
for _, hook := range HookReplyToDNSQueries {
allow, allowedName := hook(h, r)
if !allow {
continue
}
if allowedName == nil {
// Allow all names by default (still subject to names restricted by
// the netmap).
return true, h.allowExitNodeDNSProxyToServeName
}
nameFilters = append(nameFilters, allowedName)
}
if len(nameFilters) == 0 {
return false, nil
}
return true, func(name string) bool {
// Always filter out names restricted by the netmap.
if !h.allowExitNodeDNSProxyToServeName(name) {
return false
}
// Now, only allow names permitted by at least one extension.
for _, f := range nameFilters {
if f(name) {
return true
}
}
return false
}
}
// exitNodeDNSFilterForTest overrides
// peerAPIHandler.allowExitNodeDNSProxyToServeName if set during test execution.
var exitNodeDNSFilterForTest func(name string) bool
func (h *peerAPIHandler) allowExitNodeDNSProxyToServeName(name string) bool {
if testenv.InTest() && exitNodeDNSFilterForTest != nil {
return exitNodeDNSFilterForTest(name)
}
return h.ps.b.allowExitNodeDNSProxyToServeName(name)
}
// offersExitNodeOrAppConnectorAndPeerHasAutogroupInternet is run as part of
// [HookReplyToDNSQueries] and handles our legacy PeerAPI DNS acceptance
// criteria:
// - When a node is advertising an exit node it will accept DNS queries
// from peers that have access to autogroup:internet.
// - When a node is advertising an app connector, it will accept DNS queries
// to peers that have access to a relevant app.
//
// Further details about how these are accomplished are in inline comments.
func offersExitNodeOrAppConnectorAndPeerHasAutogroupInternet(h PeerAPIHandler, _ *http.Request) (bool, DNSNameFilter) {
b := h.LocalBackend()
if !b.OfferingExitNode() && !b.OfferingAppConnector() {
// If we're not an exit node or app connector, this hook
// doesn't apply.
return false, nil
}
// Otherwise, we're an exit node but the peer is not us, so
// we need to check if they're allowed access to the internet.
// As peerapi bypasses wgengine/filter checks, we need to check
@@ -709,21 +829,21 @@ func (h *peerAPIHandler) replyToDNSQueries() bool {
// in LocalBackend).
f := b.currentNode().filter()
if f == nil {
return false
return false, nil
}
// Note: we check TCP here because the Filter type already had
// a CheckTCP method (for unit tests), but it's pretty
// arbitrary. DNS runs over TCP and UDP, so sure... we check
// TCP.
dstIP := netaddr.IPv4(0, 0, 0, 0)
remoteIP := h.remoteAddr.Addr()
remoteIP := h.RemoteAddr().Addr()
if remoteIP.Is6() {
// autogroup:internet for IPv6 is defined to start with 2000::/3,
// so use 2000::0 as the probe "the internet" address.
dstIP = netip.MustParseAddr("2000::")
}
verdict := f.CheckTCP(remoteIP, dstIP, 53)
return verdict == filter.Accept
return verdict == filter.Accept, nil
}
// handleDNSQuery implements a DoH server (RFC 8484) over the peerapi.
@@ -733,7 +853,8 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
http.Error(w, "DNS not wired up", http.StatusNotImplemented)
return
}
if !h.replyToDNSQueries() {
sourceAllowed, nameAllowed := h.isPeerAPIDNSAllowed(r)
if !sourceAllowed {
http.Error(w, "DNS access denied", http.StatusForbidden)
return
}
@@ -741,9 +862,14 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
q, publicError := dohQuery(r)
if publicError != "" && r.Method == "GET" {
if name := r.FormValue("q"); name != "" {
pretty = true
publicError = ""
q = dnsQueryForName(name, r.FormValue("t"))
nameQuery, err := dnsQueryForName(name, r.FormValue("t"))
if err != nil {
publicError = "invalid name in ‘q’ parameter"
} else {
pretty = true
publicError = ""
q = nameQuery
}
}
}
if publicError != "" {
@@ -757,7 +883,7 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
ctx, cancel := context.WithTimeout(r.Context(), arbitraryTimeout)
defer cancel()
res, err := h.ps.resolver.HandlePeerDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
res, err := h.ps.resolver.HandlePeerDNSQuery(ctx, q, h.remoteAddr, nameAllowed)
if err != nil {
h.logf("handleDNS fwd error: %v", err)
if err := ctx.Err(); err != nil {
@@ -822,7 +948,10 @@ func dohQuery(r *http.Request) (dnsQuery []byte, publicErr string) {
}
}
func dnsQueryForName(name, typStr string) []byte {
// dnsQueryForName builds a DNS query for name, which comes from the caller of
// the peerAPI DNS debug mode and is not otherwise validated. It returns an
// error if name does not fit in a DNS message.
func dnsQueryForName(name, typStr string) ([]byte, error) {
typ := dnsmessage.TypeA
switch strings.ToLower(typStr) {
case "aaaa":
@@ -830,22 +959,27 @@ func dnsQueryForName(name, typStr string) []byte {
case "txt":
typ = dnsmessage.TypeTXT
}
if !strings.HasSuffix(name, ".") {
name += "."
}
qname, err := dnsmessage.NewName(name)
if err != nil {
return nil, err
}
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{
OpCode: 0, // query
RecursionDesired: true,
ID: 1, // arbitrary, but 0 is rejected by some servers
})
if !strings.HasSuffix(name, ".") {
name += "."
}
b.StartQuestions()
b.Question(dnsmessage.Question{
Name: dnsmessage.MustNewName(name),
if err := b.Question(dnsmessage.Question{
Name: qname,
Type: typ,
Class: dnsmessage.ClassINET,
})
msg, _ := b.Finish()
return msg
}); err != nil {
return nil, err
}
return b.Finish()
}
func writePrettyDNSReply(w io.Writer, res []byte) (err error) {
@@ -1009,8 +1143,10 @@ func newFakePeerAPIListener(ip netip.Addr) net.Listener {
// for a given IP on port 1 (arbitrary) and can be Closed, but otherwise Accept
// just blocks forever until closed. The purpose of this is to let the rest
// of the LocalBackend/PeerAPI code run and think it's talking to the kernel,
// even if the kernel isn't cooperating (like on Android: Issue 4449, 4293, etc)
// or we lack permission to listen on a port. It's okay to not actually listen via
// even if the kernel isn't cooperating (like on Android: Issue 4449, 4293, etc),
// we lack permission to listen on a port, or a kernel listener would be
// reachable from off the tailnet (FreeBSD; see [peerAPIServer.skipKernelListener]).
// It's okay to not actually listen via
// the kernel because on almost all platforms (except iOS as of 2022-04-20) we
// also intercept incoming netstack TCP requests to our peerapi port and hand them over
// directly to peerapi, without involving the kernel. So this doesn't need to be
+3 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
@@ -11,7 +11,7 @@ import (
"strings"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/util/httpm"
)
@@ -34,7 +34,7 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request)
}
capsMap := h.PeerCaps()
driveCaps, ok := capsMap[tailcfg.PeerCapabilityTaildrive]
driveCaps, ok := capsMap[peercap.Taildrive]
if !ok {
h.logf("taildrive: not permitted")
http.Error(w, "taildrive not permitted", http.StatusForbidden)
+50
View File
@@ -0,0 +1,50 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux && !android
package ipnlocal
import (
"net"
"net/netip"
"tailscale.com/net/netns"
)
func init() {
initListenConfig = initListenConfigTun
}
// initListenConfigTun binds the peerapi listener to the tunnel interface
// with SO_BINDTODEVICE.
//
// Linux delivers a packet addressed to the node's own Tailscale IP via
// INPUT from any interface it arrives on (weak host model), so a
// LAN-adjacent attacker could otherwise complete a TCP handshake with
// the kernel-level listener, confirming that this machine's MAC
// address belongs to a given tailnet identity (the peerapi port is
// derived from the Tailscale IP). Binding the listener to the tunnel
// interface drops those handshakes at socket lookup. macOS and iOS
// already bind this listener to the tunnel interface with IP_BOUND_IF,
// and Windows is protected by its strong host model.
//
// No legitimate traffic is lost. Connections to the node's Tailscale
// IP from the local host are delivered via the tunnel interface, peer
// connections arrive that way in builds without userspace netstack,
// and with netstack compiled in (the usual case) peer connections are
// terminated in userspace and never reach the kernel listener at all.
// In netstack mode there is no tunnel interface and the listener binds
// all addresses; the only connections that reach it are loopback dials
// from netstack and from the local host, so loopback is the right
// device there.
func initListenConfigTun(config *net.ListenConfig, addr netip.Addr, tunIfIndex int) error {
device := "lo"
if tunIfIndex != 0 {
iface, err := net.InterfaceByIndex(tunIfIndex)
if err == nil {
device = iface.Name
}
}
return netns.SetListenConfigInterfaceName(config, device)
}
+7 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_macext && (darwin || ios)
@@ -6,6 +6,7 @@
package ipnlocal
import (
"fmt"
"net"
"net/netip"
@@ -20,5 +21,10 @@ func init() {
// through the iOS/macOS Network/System Extension (Packet Tunnel
// Provider) sandbox.
func initListenConfigNetworkExtension(nc *net.ListenConfig, ip netip.Addr, ifaceIndex int) error {
// A zero ifaceIndex is invalid for peerapi. A zero value will not get us
// out of the network sandbox. Caller should log and retry.
if ifaceIndex == 0 {
return fmt.Errorf("peerapi: cannot listen on %s with ifaceIndex 0", ip)
}
return netns.SetListenConfigInterfaceIndex(nc, ifaceIndex)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
+21 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
@@ -21,6 +21,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/persist"
@@ -64,6 +65,10 @@ type profileManager struct {
// Override for key.NewEmptyHardwareAttestationKey used for testing.
newEmptyHardwareAttestationKey func() (key.HardwareAttestationKey, error)
// clock supplies the current time when stamping LoginProfile.Created.
// Tests substitute a fake clock to make creation timestamps deterministic.
clock tstime.DefaultClock
}
// SetExtensionHost sets the [ExtensionHost] for the [profileManager].
@@ -189,7 +194,7 @@ func (pm *profileManager) SwitchToProfile(profile ipn.LoginProfileView) (cp ipn.
f(pm.currentProfile, pm.prefs, false)
}
// Do not call pm.extHost.NotifyProfileChange here; it is invoked in
// [LocalBackend.resetForProfileChangeLockedOnEntry] after the netmap reset.
// [LocalBackend.resetForProfileChangeLocked] after the netmap reset.
// TODO(nickkhyl): Consider moving it here (or into the stateChangeCb handler
// in [LocalBackend]) once the profile/node state, including the netmap,
// is actually tied to the current profile.
@@ -250,7 +255,14 @@ func (pm *profileManager) allProfilesFor(uid ipn.WindowsUserID) []ipn.LoginProfi
}
}
slices.SortFunc(out, func(a, b ipn.LoginProfileView) int {
return cmp.Compare(a.Name(), b.Name())
// Legacy (zero Created) first, then stamped oldest-first.
if c := a.Created().Compare(b.Created()); c != 0 {
return c
}
if c := cmp.Compare(a.Name(), b.Name()); c != 0 {
return c
}
return cmp.Compare(a.NetworkProfile().DomainName, b.NetworkProfile().DomainName)
})
return out
}
@@ -274,7 +286,7 @@ func (pm *profileManager) matchingProfiles(uid ipn.WindowsUserID, f func(ipn.Log
func (pm *profileManager) findMatchingProfiles(uid ipn.WindowsUserID, prefs ipn.PrefsView) []ipn.LoginProfileView {
return pm.matchingProfiles(uid, func(p ipn.LoginProfileView) bool {
return p.ControlURL() == prefs.ControlURL() &&
(p.UserProfile().ID == prefs.Persist().UserProfile().ID ||
(p.UserProfile().ID() == prefs.Persist().UserProfile().ID() ||
p.NodeID() == prefs.Persist().NodeID())
})
}
@@ -337,7 +349,7 @@ func (pm *profileManager) setUnattendedModeAsConfigured() error {
// across user switches to disambiguate the same account but a different tailnet.
func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile) error {
cp := pm.currentProfile
if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName == "" {
if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName() == "" {
// We don't know anything about this profile, so ignore it for now.
return pm.setProfilePrefsNoPermCheck(pm.currentProfile, prefsIn.AsStruct().View())
}
@@ -369,7 +381,7 @@ func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile)
// WantRunning and possibly other fields. This may not be the desired behavior.
//
// Additionally, LocalBackend doesn't treat it as a proper profile switch, meaning that
// [LocalBackend.resetForProfileChangeLockedOnEntry] is not called and certain
// [LocalBackend.resetForProfileChangeLocked] is not called and certain
// node/profile-specific state may not be reset as expected.
//
// However, [profileManager] notifies [ipnext.Extension]s about the profile change,
@@ -410,9 +422,10 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
// and it hasn't been persisted yet. We'll generate both an ID and [ipn.StateKey]
// once the information is available and needs to be persisted.
if lp.ID == "" {
if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName != "" {
if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName() != "" {
// Generate an ID and [ipn.StateKey] now that we have the node info.
lp.ID, lp.Key = newUnusedID(pm.knownProfiles)
lp.Created = pm.clock.Now()
}
// Set the current user as the profile owner, unless the current user ID does
@@ -425,7 +438,7 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref
var up tailcfg.UserProfile
if persist := prefsIn.Persist(); persist.Valid() {
up = persist.UserProfile()
up = *persist.UserProfile().AsStruct()
if up.DisplayName == "" {
up.DisplayName = up.LoginName
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
+198
View File
@@ -0,0 +1,198 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"net/netip"
"strings"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/dnsname"
"tailscale.com/wgengine"
"tailscale.com/wgengine/wgcfg"
)
// lookupPeerByIP returns the node public key for the peer that should
// handle traffic to the given IP address. It is installed as the
// [wgengine.Engine.SetPeerByIPPacketFunc] callback: exact node
// addresses hit the nodeByAddr fast path, and subnet routes and
// exit-node default routes fall back to the RouteManager's outbound
// table, so it stays correct under incremental netmap deltas.
//
// It is called by wireguard-go on every outbound packet (not cached),
// so it must be fast.
func (b *LocalBackend) lookupPeerByIP(ip netip.Addr) (key.NodePublic, bool) {
nb := b.currentNode()
if nid, ok := nb.NodeByAddr(ip); ok {
peer, ok := nb.NodeByID(nid)
if !ok {
return key.NodePublic{}, false
}
return peer.Key(), true
}
if pr, ok := nb.routeMgr.Outbound().Lookup(ip); ok {
return pr.Key, true
}
return key.NodePublic{}, false
}
// peerConfig returns the WireGuard configuration for the peer with the given
// public key, or ok=false if the peer is unknown (or currently routable via no
// prefix at all). It is installed as the
// [wgengine.Engine.SetPeerConfigFunc] callback, backing wireguard-go's
// lazy peer creation and per-delta peer sync.
func (b *LocalBackend) peerConfig(k key.NodePublic) (_ wgcfg.PeerConfig, ok bool) {
ips, ok := b.currentNode().PeerAllowedIPs(k)
return wgcfg.PeerConfig{AllowedIPs: ips}, ok
}
// resolveMagicDNS resolves a MagicDNS hostname to the owning node's IP
// address, respecting the requested network address family ("tcp4",
// "tcp6", "tcp", etc.). It accepts peer FQDNs ("foo.tail-scale.ts.net"),
// short names ("foo"), and DNS.ExtraRecords entries (service VIPs).
// The hostname must be lowercase with no trailing dot. It is installed
// as the [tsdial.Dialer.SetResolveMagicDNS] callback.
func (b *LocalBackend) resolveMagicDNS(hostname, network string) (_ netip.Addr, ok bool) {
nb := b.currentNode()
if nid, ok := nb.NodeByName(hostname); ok {
n, ok := nb.NodeByID(nid)
if !ok {
b.logf("[unexpected] resolveMagicDNS: NodeByName(%q) returned node %v but NodeByID failed", hostname, nid)
return netip.Addr{}, false
}
if ip, ok := nodeAddrForNetwork(n, network); ok {
return ip, true
}
return netip.Addr{}, false
}
if ip, ok := nb.ExtraDNSByName(hostname); ok && addrFamilyMatch(ip, network) {
return ip, true
}
return netip.Addr{}, false
}
// magicDNSHosts implements [resolver.MagicDNSHosts] on top of the
// current nodeBackend's live node indexes, so the quad-100 resolver
// pulls each MagicDNS answer on demand rather than LocalBackend
// pushing a Hosts map of every node into it on every netmap change.
// It is installed once at LocalBackend construction; going through
// currentNode makes profile switches take effect immediately.
// Short names ("foo") are only resolved if MagicDNS is enabled
// for the current tailnet.
type magicDNSHosts struct{ b *LocalBackend }
func (m magicDNSHosts) LookupHost(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) {
if !buildfeatures.HasDNS {
return nil, false
}
return m.b.currentNode().magicDNSHostAddrs(fqdn)
}
func (m magicDNSHosts) LookupPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) {
if !buildfeatures.HasDNS {
return "", false
}
return m.b.currentNode().magicDNSPTR(ip)
}
func (m magicDNSHosts) SubdomainHost(fqdn dnsname.FQDN) bool {
if !buildfeatures.HasDNS {
return false
}
return m.b.currentNode().magicDNSSubdomainHost(fqdn)
}
var _ resolver.MagicDNSHosts = magicDNSHosts{}
// nodeAddrForNetwork returns the best address from n for the given
// network ("tcp", "tcp4", "tcp6", "udp", "udp4", "udp6"). For
// unqualified networks ("tcp", "udp"), it prefers IPv4.
func nodeAddrForNetwork(n tailcfg.NodeView, network string) (_ netip.Addr, ok bool) {
addrs := n.Addresses()
if addrs.Len() == 0 {
return netip.Addr{}, false
}
want4 := strings.HasSuffix(network, "4")
want6 := strings.HasSuffix(network, "6")
var v6 netip.Addr
for _, pfx := range addrs.All() {
ip := pfx.Addr()
if want4 && ip.Is4() {
return ip, true
}
if want6 && ip.Is6() {
return ip, true
}
if !want4 && !want6 {
if ip.Is4() {
return ip, true
}
if !v6.IsValid() {
v6 = ip
}
}
}
if v6.IsValid() {
return v6, true
}
return netip.Addr{}, false
}
// addrFamilyMatch reports whether ip is compatible with the requested
// network address family.
func addrFamilyMatch(ip netip.Addr, network string) bool {
if strings.HasSuffix(network, "4") {
return ip.Is4()
}
if strings.HasSuffix(network, "6") {
return ip.Is6()
}
return true
}
// PeerForIP returns which peer is responsible for a given IP address.
// Despite the name, it can also return the self node (with IsSelf set).
// It handles both Tailscale IPs (returning the owning peer or self) and
// non-Tailscale addresses like subnet-routed IPs or exit-node global
// internet IPs (returning whichever peer would route that traffic).
// It is installed as the [wgengine.Engine.SetPeerForIPFunc] callback,
// serving the engine's internal cold-path lookups (Ping, TSMP, pendopen
// diagnostics).
func (b *LocalBackend) PeerForIP(ip netip.Addr) (_ wgengine.PeerForIP, ok bool) {
nb := b.currentNode()
if tsaddr.IsTailscaleIP(ip) {
if nid, ok := nb.NodeByAddr(ip); ok {
n, ok := nb.NodeByID(nid)
if !ok {
b.logf("[unexpected] peerForIP: NodeByAddr(%v) returned node %v but NodeByID failed", ip, nid)
return wgengine.PeerForIP{}, false
}
self := nb.Self()
return wgengine.PeerForIP{
Node: n,
IsSelf: self.Valid() && self.ID() == nid,
Route: netip.PrefixFrom(ip, ip.BitLen()),
}, true
}
}
route, pr, ok := nb.routeMgr.Outbound().LookupPrefixLPM(netip.PrefixFrom(ip, ip.BitLen()))
if !ok {
return wgengine.PeerForIP{}, false
}
nid, ok := nb.NodeByKey(pr.Key)
if !ok {
return wgengine.PeerForIP{}, false
}
n, ok := nb.NodeByID(nid)
if !ok {
return wgengine.PeerForIP{}, false
}
return wgengine.PeerForIP{Node: n, Route: route}, true
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_routecheck
package ipnlocal
import "tailscale.com/tailcfg"
func isRouteCheckEnabled(self tailcfg.NodeView) bool {
return false
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package ipnlocal
import (
"tailscale.com/net/routecheck"
"tailscale.com/tailcfg"
)
func isRouteCheckEnabled(self tailcfg.NodeView) bool {
return routecheck.IsEnabled(self)
}
+199 -99
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
@@ -35,11 +35,13 @@ import (
"github.com/pires/go-proxyproto"
"go4.org/mem"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/net/netmon"
"tailscale.com/net/netutil"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/types/views"
@@ -48,6 +50,7 @@ import (
"tailscale.com/util/ctxkey"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
"tailscale.com/util/usermetric"
"tailscale.com/version"
)
@@ -91,7 +94,7 @@ type serveHTTPContext struct {
// provides funnel-specific context, nil if not funneled
Funnel *funnelFlow
// AppCapabilities lists all PeerCapabilities that should be forwarded by serve
AppCapabilities views.Slice[tailcfg.PeerCapability]
AppCapabilities views.Slice[peercap.Cap]
}
// funnelFlow represents a funneled connection initiated via IngressPeer
@@ -121,8 +124,22 @@ type localListener struct {
handler func(net.Conn) error // handler for inbound connections
closeListener syncs.AtomicValue[func() error] // Listener's Close method, if any
// allowAllInterfaces, if set, stops the listener from being bound to the
// Tailscale interface, so it answers traffic arriving on any interface.
// Only serve listeners set it, and only when serveAllowAllInterfaces is
// set; the web client listener always stays bound.
allowAllInterfaces bool
}
// serveAllowAllInterfaces, when set, stops serve's kernel listeners from
// being bound to the Tailscale interface, so they answer traffic arriving on
// any interface. It applies only to serve listeners, not the web client
// listener. This re-exposes serve to the local network; it is set only by the
// Kubernetes operator's experimental cluster-traffic ingress feature, which
// forwards cluster traffic to the node's Tailscale IP via another interface.
var serveAllowAllInterfaces = envknob.RegisterBool("TS_SERVE_ALLOW_ALL_INTERFACES")
func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *localListener {
ctx, cancel := context.WithCancel(ctx)
return &localListener{
@@ -132,6 +149,8 @@ func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort,
cancel: cancel,
logf: logf,
allowAllInterfaces: serveAllowAllInterfaces(),
handler: func(conn net.Conn) error {
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
handler := b.tcpHandlerForServe(ap.Port(), srcAddr, nil)
@@ -166,7 +185,7 @@ func (s *localListener) Run() {
ipStr := ip.String()
var lc net.ListenConfig
if initListenConfig != nil {
if initListenConfig != nil && !s.allowAllInterfaces {
ifIndex, err := netmon.TailscaleInterfaceIndex()
if err != nil {
s.logf("localListener failed to get Tailscale interface index %v, backing off: %v", s.ap, err)
@@ -220,14 +239,13 @@ func (s *localListener) Run() {
s.closeListener.Store(ln.Close)
s.logf("listening on %v", s.ap)
// handleListenersAccept always returns a non-nil error.
err = s.handleListenersAccept(ln)
if s.ctx.Err() != nil {
// context canceled, we're done
return
}
if err != nil {
s.logf("localListener accept error, retrying: %v", err)
}
s.logf("localListener accept error, retrying: %v", err)
}
}
@@ -276,7 +294,7 @@ func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint1
}
}
nm := b.NetMap()
nm := b.NetMapNoPeers()
if nm == nil {
b.logf("netMap is nil")
return
@@ -333,7 +351,7 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
}
nm := b.NetMap()
nm := b.NetMapNoPeers()
if nm == nil {
return errors.New("netMap is nil")
}
@@ -534,6 +552,56 @@ func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcf
return servicesList
}
type serviceMeteredConn struct {
net.Conn
inbound, outbound *usermetric.MultiLabelMap[serveLabels]
key serveLabels
}
func (c *serviceMeteredConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.inbound.Add(c.key, int64(n))
}
return n, err
}
func (c *serviceMeteredConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
if n > 0 {
c.outbound.Add(c.key, int64(n))
}
return n, err
}
// CloseWrite forwards a write-half close to the underlying conn. We only embed
// the net.Conn interface, which would otherwise hide the underlying conn's
// CloseWrite; net/http's server relies on it (closeWriteAndWait) to send a FIN
// and drain gracefully when a connection won't be reused, avoiding a truncating
// RST on the final response.
func (c *serviceMeteredConn) CloseWrite() error {
if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok {
return cw.CloseWrite()
}
return nil
}
// meteredConnForService wraps c to count peer bytes against the per-Service
// Serve counters. The per-Service series is never evicted, so it leaks
// (intentionally) until tailscaled exits.
func (b *LocalBackend) meteredConnForService(c net.Conn, svc tailcfg.ServiceName) net.Conn {
// Plain (non-Service) serve passes an empty svc; don't meter it.
if svc == "" || b.metrics.serveBytesInbound == nil || b.metrics.serveBytesOutbound == nil {
return c
}
return &serviceMeteredConn{
Conn: c,
inbound: b.metrics.serveBytesInbound,
outbound: b.metrics.serveBytesOutbound,
key: serveLabels{Service: svc.String()},
}
}
// tcpHandlerForVIPService returns a handler for a TCP connection to a VIP service
// that is being served via the ipn.ServeConfig. It returns nil if the destination
// address is not a VIP service or if the VIP service does not have a TCP handler set.
@@ -560,68 +628,14 @@ func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort)
return nil
}
if tcph.HTTPS() || tcph.HTTP() {
hs := &http.Server{
Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
SrcAddr: srcAddr,
ForVIPService: dstSvc,
DestPort: dport,
})
},
}
if tcph.HTTPS() {
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
// in the hostname. How to store the TLS cert is still being discussed.
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport, dstSvc),
}
return func(c net.Conn) error {
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
}
}
return func(c net.Conn) error {
return hs.Serve(netutil.NewOneConnListener(c, nil))
}
}
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
cancel()
if err != nil {
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
return nil
}
defer backConn.Close()
if sni := tcph.TerminateTLS(); sni != "" {
conn = tls.Server(conn, &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
},
})
}
return b.forwardTCPWithProxyProtocol(conn, backConn, tcph.ProxyProtocol(), srcAddr, dport, backDst)
}
}
return nil
// TODO(kevinliang10): just leaving this TLS cert creation as if we don't have other
// hostnames, but for services this getTLSServeCetForPort will need a version that also take
// in the hostname. How to store the TLS cert is still being discussed.
return b.tcpHandlerForServeTCP(tcph, dport, srcAddr, &serveHTTPContext{
SrcAddr: srcAddr,
ForVIPService: dstSvc,
DestPort: dport,
}, dstSvc)
}
// tcpHandlerForServe returns a handler for a TCP connection to be served via
@@ -641,27 +655,32 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
return nil
}
return b.tcpHandlerForServeTCP(tcph, dport, srcAddr, &serveHTTPContext{
Funnel: f,
SrcAddr: srcAddr,
DestPort: dport,
}, "")
}
func (b *LocalBackend) tcpHandlerForServeTCP(tcph ipn.TCPPortHandlerView, dport uint16, srcAddr netip.AddrPort, httpCtx *serveHTTPContext, forVIPService tailcfg.ServiceName) func(net.Conn) error {
if tcph.HTTPS() || tcph.HTTP() {
hs := &http.Server{
Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
Funnel: f,
SrcAddr: srcAddr,
DestPort: dport,
})
c := *httpCtx
return serveHTTPContextKey.WithValue(context.Background(), &c)
},
}
if tcph.HTTPS() {
hs.TLSConfig = &tls.Config{
GetCertificate: b.getTLSServeCertForPort(dport, ""),
}
hs.TLSConfig = b.serveTLSConfig(b.getTLSServeCertForPort(dport, forVIPService), serveTLSNextProtos())
return func(c net.Conn) error {
c = b.meteredConnForService(c, forVIPService)
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
}
}
return func(c net.Conn) error {
c = b.meteredConnForService(c, forVIPService)
return hs.Serve(netutil.NewOneConnListener(c, nil))
}
}
@@ -669,8 +688,16 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
conn = b.meteredConnForService(conn, forVIPService)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
var backConn net.Conn
var err error
if socketPath, ok := strings.CutPrefix(backDst, "unix:"); ok {
var d net.Dialer
backConn, err = d.DialContext(ctx, "unix", socketPath)
} else {
backConn, err = b.dialer.SystemDial(ctx, "tcp", backDst)
}
cancel()
if err != nil {
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
@@ -678,21 +705,22 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
}
defer backConn.Close()
if sni := tcph.TerminateTLS(); sni != "" {
conn = tls.Server(conn, &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
},
})
conn = tls.Server(conn, b.serveTLSConfig(func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if cert, ok := b.getACMETLSALPNCert(hi); ok {
return cert, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
pair, err := b.GetCertPEM(ctx, sni)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}, nil))
}
// TODO(bradfitz): do the RegisterIPPortIdentity and
@@ -710,7 +738,13 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
func (b *LocalBackend) forwardTCPWithProxyProtocol(conn, backConn net.Conn, proxyProtoVer int, srcAddr netip.AddrPort, dport uint16, backDst string) error {
var proxyHeader []byte
if proxyProtoVer > 0 {
backAddr := backConn.RemoteAddr().(*net.TCPAddr)
// PROXY protocol requires a valid TCP destination address.
// For Unix socket backends, RemoteAddr is *net.UnixAddr;
// the CLI rejects this combination, but guard here as well.
backAddr, ok := backConn.RemoteAddr().(*net.TCPAddr)
if !ok {
return fmt.Errorf("PROXY protocol is not supported with non-TCP backend %s", backDst)
}
// We always want to format the PROXY protocol header based on
// the IPv4 or IPv6-ness of the client. The SourceAddr and
@@ -814,6 +848,15 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
return h, r.URL.Path, true
}
pth := path.Clean(r.URL.Path)
// A well-formed origin-form request path is absolute. Malformed request
// targets — "*" (e.g. "GET *") and "" (e.g. "CONNECT" authority-form),
// clean to "*" and "." respectively. Those are path.Dir fixed points that
// never equal "/" and match no mount, so without this guard the loop below
// would spin forever on one CPU core (a remote DoS via serve, or via funnel
// from the internet).
if !strings.HasPrefix(pth, "/") {
return z, "", false
}
for {
withSlash := pth + "/"
if h, ok := wsc.Handlers().GetOk(withSlash); ok {
@@ -825,7 +868,13 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
if pth == "/" {
return z, "", false
}
pth = path.Dir(pth)
// Belt-and-suspenders with the absolute-path check above: stop if
// path.Dir stops shrinking rather than assuming it always reaches "/".
if parent := path.Dir(pth); parent != pth {
pth = parent
} else {
return z, "", false
}
}
}
@@ -835,8 +884,8 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, err
targetURL, insecure := expandProxyArg(backend)
// Handle unix: scheme specially
if strings.HasPrefix(targetURL, "unix:") {
socketPath := strings.TrimPrefix(targetURL, "unix:")
if after, ok := strings.CutPrefix(targetURL, "unix:"); ok {
socketPath := after
if socketPath == "" {
return nil, fmt.Errorf("empty unix socket path")
}
@@ -969,6 +1018,8 @@ func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// to the backend. The Transport gets created lazily, at most once.
func (rp *reverseProxy) getTransport() *http.Transport {
return rp.httpTransport.Get(func() *http.Transport {
// Zero preserves http.Transport's default MaxIdleConnsPerHost value.
maxIdleConnsPerHost, _ := envknob.LookupInt("TS_DEBUG_SERVE_MAX_IDLE_CONNS_PER_HOST")
dial := rp.lb.dialer.SystemDial
if rp.socketPath != "" {
dial = func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -985,6 +1036,7 @@ func (rp *reverseProxy) getTransport() *http.Transport {
// Values for the following parameters have been copied from http.DefaultTransport.
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
@@ -1106,7 +1158,7 @@ func (b *LocalBackend) addAppCapabilitiesHeader(r *httputil.ProxyRequest) error
return nil
}
peerCapsFiltered := make(map[tailcfg.PeerCapability][]tailcfg.RawMessage, acceptCaps.Len())
peerCapsFiltered := make(map[peercap.Cap][]tailcfg.RawMessage, acceptCaps.Len())
for _, cap := range acceptCaps.AsSlice() {
if peerCaps.HasCapability(cap) {
peerCapsFiltered[cap] = peerCaps[cap]
@@ -1311,6 +1363,9 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
if cert, ok := b.getACMETLSALPNCert(hi); ok {
return cert, nil
}
_, ok := b.webServerConfig(hi.ServerName, forVIPService, port)
if !ok {
return nil, errors.New("no webserver configured for name/port")
@@ -1330,6 +1385,48 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
}
}
// serveTLSConfig returns the TLS configuration used by Serve and TCP-forwarded
// TLS listeners. nextProtos is the ALPN list to advertise for normal
// handshakes; it should be serveTLSNextProtos for HTTPS Serve and nil for
// TLS-terminated TCP forwarding where we don't know the backend protocol.
// During an ACME tls-alpn-01 renewal, GetConfigForClient clones the base config
// and temporarily prepends acme-tls/1, but only for the exact SNI with a pending
// challenge certificate. This keeps ordinary Serve traffic from advertising
// ACME support and lets Go's TLS stack negotiate the challenge protocol before
// GetCertificate is called.
func (b *LocalBackend) serveTLSConfig(getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), nextProtos []string) *tls.Config {
base := &tls.Config{
GetCertificate: getCert,
NextProtos: nextProtos,
}
base.GetConfigForClient = func(hi *tls.ClientHelloInfo) (*tls.Config, error) {
var nextProtos []string
if proto, ok := b.getACMETLSALPNProto(hi); ok {
b.logf("serve: accepting ACME tls-alpn-01 challenge for %q", hi.ServerName)
nextProtos = append(nextProtos, proto)
}
if len(nextProtos) == 0 {
return nil, nil
}
cfg := base.Clone()
cfg.NextProtos = append(nextProtos, base.NextProtos...)
return cfg, nil
}
return base
}
// HasFunnelForHostPort reports whether the LocalBackend's serve config
// has Funnel enabled for host:port.
func (b *LocalBackend) HasFunnelForHostPort(host string, port uint16) bool {
b.mu.Lock()
defer b.mu.Unlock()
if !b.serveConfig.Valid() {
return false
}
hp := ipn.HostPort(net.JoinHostPort(host, strconv.Itoa(int(port))))
return b.serveConfig.HasFunnelForTarget(hp)
}
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
// backend specified in serveConfig. It expects serveConfig to be valid and
// up-to-date, so should be called after reloadServeConfigLocked.
@@ -1551,6 +1648,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if err != nil {
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
b.updateCertRefreshLoopLocked()
return
}
if b.lastServeConfJSON.Equal(mem.B(confj)) {
@@ -1561,6 +1659,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if err := json.Unmarshal(confj, &conf); err != nil {
b.logf("invalid ServeConfig %q in StateStore: %v", confKey, err)
b.serveConfig = ipn.ServeConfigView{}
b.updateCertRefreshLoopLocked()
return
}
@@ -1571,6 +1670,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
})
b.serveConfig = conf.View()
b.updateCertRefreshLoopLocked()
}
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[tailcfg.ServiceName][]uint16) {
+4 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_serve
@@ -28,6 +28,9 @@ type funnelFlow = struct{}
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
func (*LocalBackend) HasFunnelForHostPort(host string, port uint16) bool {
return false
}
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
return nil
-234
View File
@@ -1,234 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9) && !ts_omit_ssh
package ipnlocal
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"go4.org/mem"
"golang.org/x/crypto/ssh"
"tailscale.com/tailcfg"
"tailscale.com/util/lineiter"
"tailscale.com/util/mak"
)
// keyTypes are the SSH key types that we either try to read from the
// system's OpenSSH keys or try to generate for ourselves when not
// running as root.
var keyTypes = []string{"rsa", "ecdsa", "ed25519"}
// getSSHUsernames discovers and returns the list of usernames that are
// potential Tailscale SSH user targets.
//
// Invariant: must not be called with b.mu held.
func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
res := new(tailcfg.C2NSSHUsernamesResponse)
if !b.tailscaleSSHEnabled() {
return res, nil
}
max := 10
if req != nil && req.Max != 0 {
max = req.Max
}
add := func(u string) {
if req != nil && req.Exclude[u] {
return
}
switch u {
case "nobody", "daemon", "sync":
return
}
if slices.Contains(res.Usernames, u) {
return
}
if len(res.Usernames) > max {
// Enough for a hint.
return
}
res.Usernames = append(res.Usernames, u)
}
if opUser := b.operatorUserName(); opUser != "" {
add(opUser)
}
// Check popular usernames and see if they exist with a real shell.
switch runtime.GOOS {
case "darwin":
out, err := exec.Command("dscl", ".", "list", "/Users").Output()
if err != nil {
return nil, err
}
for line := range lineiter.Bytes(out) {
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '_' {
continue
}
add(string(line))
}
default:
for lr := range lineiter.File("/etc/passwd") {
line, err := lr.Value()
if err != nil {
break
}
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '#' || line[0] == '_' {
continue
}
if mem.HasSuffix(mem.B(line), mem.S("/nologin")) ||
mem.HasSuffix(mem.B(line), mem.S("/false")) {
continue
}
colon := bytes.IndexByte(line, ':')
if colon != -1 {
add(string(line[:colon]))
}
}
}
return res, nil
}
func (b *LocalBackend) GetSSH_HostKeys() (keys []ssh.Signer, err error) {
var existing map[string]ssh.Signer
if os.Geteuid() == 0 {
existing = b.getSystemSSH_HostKeys()
}
return b.getTailscaleSSH_HostKeys(existing)
}
// getTailscaleSSH_HostKeys returns the three (rsa, ecdsa, ed25519) SSH host
// keys, reusing the provided ones in existing if present in the map.
func (b *LocalBackend) getTailscaleSSH_HostKeys(existing map[string]ssh.Signer) (keys []ssh.Signer, err error) {
var keyDir string // lazily initialized $TAILSCALE_VAR/ssh dir.
for _, typ := range keyTypes {
if s, ok := existing[typ]; ok {
keys = append(keys, s)
continue
}
if keyDir == "" {
root := b.TailscaleVarRoot()
if root == "" {
return nil, errors.New("no var root for ssh keys")
}
keyDir = filepath.Join(root, "ssh")
if err := os.MkdirAll(keyDir, 0700); err != nil {
return nil, err
}
}
hostKey, err := b.hostKeyFileOrCreate(keyDir, typ)
if err != nil {
return nil, fmt.Errorf("error creating SSH host key type %q in %q: %w", typ, keyDir, err)
}
signer, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
return nil, fmt.Errorf("error parsing SSH host key type %q from %q: %w", typ, keyDir, err)
}
keys = append(keys, signer)
}
return keys, nil
}
var keyGenMu sync.Mutex
func (b *LocalBackend) hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
keyGenMu.Lock()
defer keyGenMu.Unlock()
path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
v, err := os.ReadFile(path)
if err == nil {
return v, nil
}
if !os.IsNotExist(err) {
return nil, err
}
var priv any
switch typ {
default:
return nil, fmt.Errorf("unsupported key type %q", typ)
case "ed25519":
_, priv, err = ed25519.GenerateKey(rand.Reader)
case "ecdsa":
// curve is arbitrary. We pick whatever will at
// least pacify clients as the actual encryption
// doesn't matter: it's all over WireGuard anyway.
curve := elliptic.P256()
priv, err = ecdsa.GenerateKey(curve, rand.Reader)
case "rsa":
// keySize is arbitrary. We pick whatever will at
// least pacify clients as the actual encryption
// doesn't matter: it's all over WireGuard anyway.
const keySize = 2048
priv, err = rsa.GenerateKey(rand.Reader, keySize)
}
if err != nil {
return nil, err
}
mk, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
err = os.WriteFile(path, pemGen, 0700)
return pemGen, err
}
func (b *LocalBackend) getSystemSSH_HostKeys() (ret map[string]ssh.Signer) {
for _, typ := range keyTypes {
filename := "/etc/ssh/ssh_host_" + typ + "_key"
hostKey, err := os.ReadFile(filename)
if err != nil || len(bytes.TrimSpace(hostKey)) == 0 {
continue
}
signer, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
b.logf("warning: error reading host key %s: %v (generating one instead)", filename, err)
continue
}
mak.Set(&ret, typ, signer)
}
return ret
}
func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) {
signers, err := b.GetSSH_HostKeys()
if err != nil {
return nil, err
}
var keyStrings []string
for _, signer := range signers {
keyStrings = append(keyStrings, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))))
}
return keyStrings, nil
}
// tailscaleSSHEnabled reports whether Tailscale SSH is currently enabled based
// on prefs. It returns false if there are no prefs set.
func (b *LocalBackend) tailscaleSSHEnabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
p := b.pm.CurrentPrefs()
return p.Valid() && p.RunSSH()
}
-20
View File
@@ -1,20 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_ssh || ios || android || (!linux && !darwin && !freebsd && !openbsd && !plan9)
package ipnlocal
import (
"errors"
"tailscale.com/tailcfg"
)
func (b *LocalBackend) getSSHHostKeyPublicStrings() ([]string, error) {
return nil, nil
}
func (b *LocalBackend) getSSHUsernames(*tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
return nil, errors.New("not implemented")
}
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
@@ -23,10 +23,12 @@ import (
"slices"
"time"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/health/healthmsg"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tka"
@@ -36,27 +38,46 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/tkatype"
"tailscale.com/util/httpbody"
"tailscale.com/util/mak"
"tailscale.com/util/set"
"tailscale.com/util/testenv"
)
// TODO(tom): RPC retry/backoff was broken and has been removed. Fix?
var (
errMissingNetmap = errors.New("missing netmap: verify that you are logged in")
errNetworkLockNotActive = errors.New("network-lock is not active")
tkaCompactionDefaults = tka.CompactionOptions{
MinChain: 24, // Keep at minimum 24 AUMs since head.
MinAge: 14 * 24 * time.Hour, // Keep 2 weeks of AUMs.
}
errTailnetLockNotActive = errors.New("tailnet-lock is not active")
)
// tkaMaxResponseBodySize is the response body cap for tka RPCs whose
// responses can carry per-node key signatures or missing AUMs. At 100,000
// peers and a few hundred bytes per signature, those responses reach tens
// of MB, so the cap sits far above that while still bounding what a
// malicious control server can make us buffer.
const tkaMaxResponseBodySize = 512 << 20
// IsTailnetLockNotActive reports whether the given error indicates that
// tailnet-lock is not active. Stop-gap for feature/tailnetlock to check this
// until all of this is code is moved to the feature.
func IsTailnetLockNotActive(err error) bool {
return errors.Is(err, errTailnetLockNotActive)
}
// Deprecated: use [IsTailnetLockNotActive] instead.
func IsNetworkLockNotActive(err error) bool {
return IsTailnetLockNotActive(err)
}
type tkaState struct {
profile ipn.ProfileID
authority *tka.Authority
storage tka.CompactableChonk
filtered []ipnstate.TKAPeer
// filtered tracks peers that were removed from the netmap because
// they failed tailnet lock signature verification.
filtered map[tailcfg.NodeID]ipnstate.TKAPeer
}
func (b *LocalBackend) initTKALocked() error {
@@ -76,13 +97,13 @@ func (b *LocalBackend) initTKALocked() error {
root := b.TailscaleVarRoot()
if root == "" {
b.tka = nil
b.logf("cannot fetch existing TKA state; no state directory for network-lock")
b.logf("cannot fetch existing TKA state; no state directory for tailnet-lock")
return nil
}
chonkDir := b.chonkPathLocked()
if _, err := os.Stat(chonkDir); err == nil {
// The directory exists, which means network-lock has been initialized.
// The directory exists, which means tailnet-lock has been initialized.
storage, err := tka.ChonkDir(chonkDir)
if err != nil {
return fmt.Errorf("opening tailchonk: %v", err)
@@ -92,31 +113,75 @@ func (b *LocalBackend) initTKALocked() error {
return fmt.Errorf("initializing tka: %v", err)
}
if err := authority.Compact(storage, tkaCompactionDefaults); err != nil {
b.logf("tka compaction failed: %v", err)
}
b.tka = &tkaState{
profile: cp.ID(),
authority: authority,
storage: storage,
}
b.logf("tka initialized at head %x", authority.Head())
b.logf("tka initialized at head %s", authority.Head())
}
return nil
}
// noNetworkLockStateDirWarnable is a Warnable to warn the user that Tailnet Lock data
// noTailnetLockStateDirWarnable is a Warnable to warn the user that Tailnet Lock data
// (in particular, the list of AUMs in the TKA state) is being stored in memory and will
// be lost when tailscaled restarts.
var noNetworkLockStateDirWarnable = health.Register(&health.Warnable{
var noTailnetLockStateDirWarnable = health.Register(&health.Warnable{
Code: "no-tailnet-lock-state-dir",
Title: "No statedir for Tailnet Lock",
Severity: health.SeverityMedium,
Text: health.StaticMessage(healthmsg.InMemoryTailnetLockState),
})
// tkaFilterDeltaMutsLocked drops any [netmap.NodeMutationUpsert] in muts
// whose peer would fail tailnet lock signature verification, replacing
// each such upsert with a [netmap.NodeMutationRemove] for the same node
// ID. This matches the semantics of [tkaFilterNetmapLocked] on a full
// netmap: an unsigned (or invalidly-signed) peer must not land in
// [nodeBackend.peers], and a previously-signed peer at the same node ID
// must be evicted if the latest state from control fails verification.
//
// If tailnet lock is not active on this node (b.tka == nil) muts is
// returned unchanged. The returned slice may share backing storage with
// the input.
//
// b.mu must be held.
func (b *LocalBackend) tkaFilterDeltaMutsLocked(muts []netmap.NodeMutation) []netmap.NodeMutation {
if b.tka == nil {
return muts
}
if envknob.TKASkipSignatureCheck() {
return muts
}
for i, m := range muts {
switch m := m.(type) {
case netmap.NodeMutationUpsert:
n := m.Node
if n.UnsignedPeerAPIOnly() {
continue
}
var why string
if n.KeySignature().Len() == 0 {
why = "missing signature"
} else if err := b.tka.authority.NodeKeyAuthorized(n.Key(), n.KeySignature().AsSlice()); err != nil {
why = fmt.Sprintf("failed signature check: %v", err)
} else {
continue
}
b.logf("Tailnet lock is dropping delta-upserted peer %v(%v) due to %v", n.ID(), n.StableID(), why)
mak.Set(&b.tka.filtered, n.ID(), tkaStateFromPeer(n))
muts[i] = netmap.MakeNodeMutationRemove(n.ID())
case netmap.NodeMutationRemove:
// If a peer is explicitly removed by control, clear it from
// the filtered set too so TailnetLockStatus doesn't report
// stale entries.
delete(b.tka.filtered, m.NodeIDBeingMutated())
}
}
return muts
}
// tkaFilterNetmapLocked checks the signatures on each node key, dropping
// nodes from the netmap whose signature does not verify.
//
@@ -139,12 +204,12 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
continue
}
if p.KeySignature().Len() == 0 {
b.logf("Network lock is dropping peer %v(%v) due to missing signature", p.ID(), p.StableID())
b.logf("Tailnet lock is dropping peer %v(%v) due to missing signature", p.ID(), p.StableID())
mak.Set(&toDelete, i, true)
} else {
details, err := b.tka.authority.NodeKeyAuthorizedWithDetails(p.Key(), p.KeySignature().AsSlice())
if err != nil {
b.logf("Network lock is dropping peer %v(%v) due to failed signature check: %v", p.ID(), p.StableID(), err)
b.logf("Tailnet lock is dropping peer %v(%v) due to failed signature check: %v", p.ID(), p.StableID(), err)
mak.Set(&toDelete, i, true)
continue
}
@@ -160,16 +225,15 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
// nm.Peers is ordered, so deletion must be order-preserving.
if len(toDelete) > 0 || len(obsoleteByRotation) > 0 {
peers := make([]tailcfg.NodeView, 0, len(nm.Peers))
filtered := make([]ipnstate.TKAPeer, 0, len(toDelete)+len(obsoleteByRotation))
filtered := make(map[tailcfg.NodeID]ipnstate.TKAPeer, len(toDelete)+len(obsoleteByRotation))
for i, p := range nm.Peers {
if !toDelete[i] && !obsoleteByRotation.Contains(p.Key()) {
peers = append(peers, p)
} else {
if obsoleteByRotation.Contains(p.Key()) {
b.logf("Network lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID())
b.logf("Tailnet lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID())
}
// Record information about the node we filtered out.
filtered = append(filtered, tkaStateFromPeer(p))
filtered[p.ID()] = tkaStateFromPeer(p)
}
}
nm.Peers = peers
@@ -304,7 +368,11 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
wantEnabled := nm.TKAEnabled
if isEnabled || wantEnabled {
b.logf("tkaSyncIfNeeded: isEnabled=%t, wantEnabled=%t, head=%v", isEnabled, wantEnabled, nm.TKAHead)
nodeHead := "<not-enabled>"
if b.tka != nil {
nodeHead = b.tka.authority.Head().String()
}
b.logf("tkaSyncIfNeeded: isEnabled=%t, wantEnabled=%t, nodeHead=%v, netmapHead=%v", isEnabled, wantEnabled, nodeHead, nm.TKAHead)
}
ourNodeKey, ok := prefs.Persist().PublicNodeKeyOK()
@@ -360,7 +428,7 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
//
// We run this on every sync so that clients compact consistently. In many
// cases this will be a no-op.
if err := b.tka.authority.Compact(b.tka.storage, tkaCompactionDefaults); err != nil {
if err := b.tka.authority.Compact(b.tka.storage, tka.CompactionDefaults); err != nil {
return fmt.Errorf("tka compact: %w", err)
}
}
@@ -407,7 +475,7 @@ func (b *LocalBackend) tkaSyncLocked(ourNodeKey key.NodePublic) error {
// has updates for us, or we have updates for the control plane.
//
// TODO(tom): Do we want to keep processing even if the Inform fails? Need
// to think through if theres holdback concerns here or not.
// to think through if there's holdback concerns here or not.
if len(offerResp.MissingAUMs) > 0 {
aums := make([]tka.AUM, len(offerResp.MissingAUMs))
for i, a := range offerResp.MissingAUMs {
@@ -491,8 +559,8 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
root := b.TailscaleVarRoot()
var storage tka.CompactableChonk
if root == "" {
b.health.SetUnhealthy(noNetworkLockStateDirWarnable, nil)
b.logf("network-lock using in-memory storage; no state directory")
b.health.SetUnhealthy(noTailnetLockStateDirWarnable, nil)
b.logf("tailnet-lock using in-memory storage; no state directory")
storage = tka.ChonkMem()
} else {
chonkDir := b.chonkPathLocked()
@@ -515,9 +583,9 @@ func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM, per
return nil
}
// NetworkLockStatus returns a structure describing the state of the
// TailnetLockStatus returns a structure describing the state of the
// tailnet key authority, if any.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
func (b *LocalBackend) TailnetLockStatus() *ipnstate.TailnetLockStatus {
b.mu.Lock()
defer b.mu.Unlock()
@@ -532,13 +600,13 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
if nlPriv.IsZero() {
return &ipnstate.NetworkLockStatus{
return &ipnstate.TailnetLockStatus{
Enabled: false,
NodeKey: nodeKey,
}
}
if b.tka == nil {
return &ipnstate.NetworkLockStatus{
return &ipnstate.TailnetLockStatus{
Enabled: false,
NodeKey: nodeKey,
PublicKey: nlPriv.Public(),
@@ -570,9 +638,9 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
}
filtered := make([]*ipnstate.TKAPeer, len(b.tka.filtered))
for i := range len(filtered) {
filtered[i] = b.tka.filtered[i].Clone()
var filtered []*ipnstate.TKAPeer
for _, fp := range b.tka.filtered {
filtered = append(filtered, new(fp))
}
var visible []*ipnstate.TKAPeer
@@ -586,7 +654,7 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
stateID1, _ := b.tka.authority.StateIDs()
return &ipnstate.NetworkLockStatus{
return &ipnstate.TailnetLockStatus{
Enabled: true,
Head: &head,
PublicKey: nlPriv.Public(),
@@ -600,6 +668,11 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
}
}
// Deprecated: use [LocalBackend.TailnetLockStatus] instead.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.TailnetLockStatus {
return b.TailnetLockStatus()
}
func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
fp := ipnstate.TKAPeer{
Name: p.Name(),
@@ -620,7 +693,7 @@ func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
return fp
}
// NetworkLockInit enables network-lock for the tailnet, with the tailnets'
// TailnetLockInit enables tailnet-lock for the tailnet, with the tailnets'
// key authority initialized to trust the provided keys.
//
// Initialization involves two RPCs with control, termed 'begin' and 'finish'.
@@ -628,8 +701,8 @@ func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer {
// encodes the initial state of the authority, and the list of all nodes
// needing signatures is returned as a response.
// The Finish RPC submits signatures for all these nodes, at which point
// Control has everything it needs to atomically enable network lock.
func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
// Control has everything it needs to atomically enable tailnet lock.
func (b *LocalBackend) TailnetLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
var ourNodeKey key.NodePublic
var nlPriv key.NLPrivate
@@ -654,21 +727,16 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
// the filesystem until we've finished the initialization sequence,
// just in case something goes wrong.
_, genesisAUM, err := tka.Create(tka.ChonkMem(), tka.State{
Keys: keys,
// TODO(tom): s/tka.State.DisablementSecrets/tka.State.DisablementValues
// This will center on consistent nomenclature:
// - DisablementSecret: value needed to disable.
// - DisablementValue: the KDF of the disablement secret, a public value.
DisablementSecrets: disablementValues,
StateID1: binary.LittleEndian.Uint64(entropy[:8]),
StateID2: binary.LittleEndian.Uint64(entropy[8:]),
Keys: keys,
DisablementValues: disablementValues,
StateID1: binary.LittleEndian.Uint64(entropy[:8]),
StateID2: binary.LittleEndian.Uint64(entropy[8:]),
}, nlPriv)
if err != nil {
return fmt.Errorf("tka.Create: %v", err)
}
b.logf("Generated genesis AUM to initialize network lock, trusting the following keys:")
b.logf("Generated genesis AUM to initialize tailnet lock, trusting the following keys:")
for i, k := range genesisAUM.State.Keys {
b.logf(" - key[%d] = tlpub:%x with %d votes", i, k.Public, k.Votes)
}
@@ -683,7 +751,7 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
// node-key signatures, we need to sign keys for all the existing nodes.
// If we don't get these signatures ahead of time, everyone will lose
// connectivity because control won't have any signatures to send which
// satisfy network-lock checks.
// satisfy tailnet-lock checks.
sigs := make(map[tailcfg.NodeID]tkatype.MarshaledSignature, len(initResp.NeedSignatures))
for _, nodeInfo := range initResp.NeedSignatures {
nks, err := signNodeKey(nodeInfo, nlPriv)
@@ -699,40 +767,54 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byt
return err
}
// NetworkLockAllowed reports whether the node is allowed to use Tailnet Lock.
func (b *LocalBackend) NetworkLockAllowed() bool {
// Deprecated: use [LocalBackend.TailnetLockInit] instead.
func (b *LocalBackend) NetworkLockInit(keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) error {
return b.TailnetLockInit(keys, disablementValues, supportDisablement)
}
// TailnetLockAllowed reports whether the node is allowed to use Tailnet Lock.
func (b *LocalBackend) TailnetLockAllowed() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.capTailnetLock
}
// Only use is in tests.
func (b *LocalBackend) NetworkLockVerifySignatureForTest(nks tkatype.MarshaledSignature, nodeKey key.NodePublic) error {
// Deprecated: use [LocalBackend.TailnetLockAllowed] instead.
func (b *LocalBackend) NetworkLockAllowed() bool {
return b.TailnetLockAllowed()
}
// TailnetLockVerifySignature verifies that nks is a valid tailnet lock
// signature for the given node key.
func (f forTest) TailnetLockVerifySignature(nks tkatype.MarshaledSignature, nodeKey key.NodePublic) error {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
return b.tka.authority.NodeKeyAuthorized(nodeKey, nks)
}
// Only use is in tests.
func (b *LocalBackend) NetworkLockKeyTrustedForTest(keyID tkatype.KeyID) bool {
// TailnetLockKeyTrusted reports whether keyID is trusted by the tailnet lock
// authority. It panics if tailnet lock is not initialized.
func (f forTest) TailnetLockKeyTrusted(keyID tkatype.KeyID) bool {
b := f.b
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
panic("network lock not initialized")
panic("tailnet lock not initialized")
}
return b.tka.authority.KeyTrusted(keyID)
}
// NetworkLockForceLocalDisable shuts down TKA locally, and denylists the current
// TailnetLockForceLocalDisable shuts down TKA locally, and denylists the current
// TKA from being initialized locally in future.
func (b *LocalBackend) NetworkLockForceLocalDisable() error {
func (b *LocalBackend) TailnetLockForceLocalDisable() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
id1, id2 := b.tka.authority.StateIDs()
@@ -752,9 +834,14 @@ func (b *LocalBackend) NetworkLockForceLocalDisable() error {
return nil
}
// NetworkLockSign signs the given node-key and submits it to the control plane.
// Deprecated: use [LocalBackend.TailnetLockForceLocalDisable] instead.
func (b *LocalBackend) NetworkLockForceLocalDisable() error {
return b.TailnetLockForceLocalDisable()
}
// TailnetLockSign signs the given node-key and submits it to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
func (b *LocalBackend) TailnetLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
ourNodeKey, sig, err := func(nodeKey key.NodePublic, rotationPublic []byte) (key.NodePublic, tka.NodeKeySignature, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -768,7 +855,7 @@ func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []
}
if b.tka == nil {
return key.NodePublic{}, tka.NodeKeySignature{}, errNetworkLockNotActive
return key.NodePublic{}, tka.NodeKeySignature{}, errTailnetLockNotActive
}
if !b.tka.authority.KeyTrusted(nlPriv.KeyID()) {
return key.NodePublic{}, tka.NodeKeySignature{}, errors.New(tsconst.TailnetLockNotTrustedMsg)
@@ -795,18 +882,23 @@ func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []
return err
}
b.logf("Generated network-lock signature for %v, submitting to control plane", nodeKey)
b.logf("Generated tailnet-lock signature for %v, submitting to control plane", nodeKey)
if _, err := b.tkaSubmitSignature(ourNodeKey, sig.Serialize()); err != nil {
return err
}
return nil
}
// NetworkLockModify adds and/or removes keys in the tailnet's key authority.
func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err error) {
// Deprecated: use [LocalBackend.TailnetLockSign] instead.
func (b *LocalBackend) NetworkLockSign(nodeKey key.NodePublic, rotationPublic []byte) error {
return b.TailnetLockSign(nodeKey, rotationPublic)
}
// TailnetLockModify adds and/or removes keys in the tailnet's key authority.
func (b *LocalBackend) TailnetLockModify(addKeys, removeKeys []tka.Key) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("modify network-lock keys: %w", err)
err = fmt.Errorf("modify tailnet-lock keys: %w", err)
}
}()
@@ -829,7 +921,7 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err
return errMissingNetmap
}
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
if !b.tka.authority.KeyTrusted(nlPriv.KeyID()) {
return errors.New("this node does not have a trusted tailnet lock key")
@@ -882,8 +974,13 @@ func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err err
return nil
}
// NetworkLockDisable disables network-lock using the provided disablement secret.
func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
// Deprecated: use [LocalBackend.TailnetLockModify] instead.
func (b *LocalBackend) NetworkLockModify(addKeys, removeKeys []tka.Key) (err error) {
return b.TailnetLockModify(addKeys, removeKeys)
}
// TailnetLockDisable disables tailnet-lock using the provided disablement secret.
func (b *LocalBackend) TailnetLockDisable(secret []byte) error {
var (
ourNodeKey key.NodePublic
head tka.AUMHash
@@ -895,7 +992,7 @@ func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
ourNodeKey = p.Persist().PublicNodeKey()
}
if b.tka == nil {
err = errNetworkLockNotActive
err = errTailnetLockNotActive
} else {
head = b.tka.authority.Head()
if !b.tka.authority.ValidDisablement(secret) {
@@ -914,16 +1011,21 @@ func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
return err
}
// NetworkLockLog returns the changelog of TKA state up to maxEntries in size.
func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
// Deprecated: use [LocalBackend.TailnetLockDisable] instead.
func (b *LocalBackend) NetworkLockDisable(secret []byte) error {
return b.TailnetLockDisable(secret)
}
// TailnetLockLog returns the changelog of TKA state up to maxEntries in size.
func (b *LocalBackend) TailnetLockLog(maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
var out []ipnstate.NetworkLockUpdate
var out []ipnstate.TailnetLockUpdate
cursor := b.tka.authority.Head()
for range maxEntries {
aum, err := b.tka.storage.AUM(cursor)
@@ -934,7 +1036,7 @@ func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpd
return out, fmt.Errorf("reading AUM (%v): %w", cursor, err)
}
update := ipnstate.NetworkLockUpdate{
update := ipnstate.TailnetLockUpdate{
Hash: cursor,
Change: aum.MessageKind.String(),
Raw: aum.Serialize(),
@@ -951,9 +1053,14 @@ func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.NetworkLockUpd
return out, nil
}
// NetworkLockAffectedSigs returns the signatures which would be invalidated
// Deprecated: use [LocalBackend.TailnetLockLog] instead.
func (b *LocalBackend) NetworkLockLog(maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
return b.TailnetLockLog(maxEntries)
}
// TailnetLockAffectedSigs returns the signatures which would be invalidated
// by removing trust in the specified KeyID.
func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
func (b *LocalBackend) TailnetLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
var (
ourNodeKey key.NodePublic
err error
@@ -963,7 +1070,7 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
ourNodeKey = p.Persist().PublicNodeKey()
}
if b.tka == nil {
err = errNetworkLockNotActive
err = errTailnetLockNotActive
}
b.mu.Unlock()
if err != nil {
@@ -978,7 +1085,7 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
// Confirm for ourselves tha the signatures would actually be invalidated
@@ -1009,16 +1116,21 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
return resp.Signatures, nil
}
// NetworkLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// Deprecated: use [LocalBackend.TailnetLockAffectedSigs] instead.
func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
return b.TailnetLockAffectedSigs(keyID)
}
// TailnetLockGenerateRecoveryAUM generates an AUM which retroactively removes trust in the
// specified keys. This AUM is signed by the current node and returned.
//
// If forkFrom is specified, it is used as the parent AUM to fork from. If the zero value,
// the parent AUM is determined automatically.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
func (b *LocalBackend) TailnetLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
@@ -1042,16 +1154,21 @@ func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID
return aum, nil
}
// NetworkLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// Deprecated: use [LocalBackend.TailnetLockGenerateRecoveryAUM] instead.
func (b *LocalBackend) NetworkLockGenerateRecoveryAUM(removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) (*tka.AUM, error) {
return b.TailnetLockGenerateRecoveryAUM(removeKeys, forkFrom)
}
// TailnetLockCosignRecoveryAUM co-signs the provided recovery AUM and returns
// the updated structure.
//
// The recovery AUM provided should be the output from a previous call to
// NetworkLockGenerateRecoveryAUM or NetworkLockCosignRecoveryAUM.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
// [LocalBackend.TailnetLockGenerateRecoveryAUM] or [LocalBackend.TailnetLockCosignRecoveryAUM].
func (b *LocalBackend) TailnetLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return nil, errNetworkLockNotActive
return nil, errTailnetLockNotActive
}
var nlPriv key.NLPrivate
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() {
@@ -1076,11 +1193,16 @@ func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, err
return aum, nil
}
func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
// Deprecated: use [LocalBackend.TailnetLockCosignRecoveryAUM] instead.
func (b *LocalBackend) NetworkLockCosignRecoveryAUM(aum *tka.AUM) (*tka.AUM, error) {
return b.TailnetLockCosignRecoveryAUM(aum)
}
func (b *LocalBackend) TailnetLockSubmitRecoveryAUM(aum *tka.AUM) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return errNetworkLockNotActive
return errTailnetLockNotActive
}
var ourNodeKey key.NodePublic
if p := b.pm.CurrentPrefs(); p.Valid() && p.Persist().Valid() && !p.Persist().PrivateNodeKey().IsZero() {
@@ -1096,19 +1218,24 @@ func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
return err
}
// Deprecated: use [LocalBackend.TailnetLockSubmitRecoveryAUM] instead.
func (b *LocalBackend) NetworkLockSubmitRecoveryAUM(aum *tka.AUM) error {
return b.TailnetLockSubmitRecoveryAUM(aum)
}
var tkaSuffixEncoder = base64.RawStdEncoding
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// TailnetLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
//
// The provided trusted tailnet-lock key is used to sign
// a SigCredential structure, which is encoded along with the
// private key and appended to the pre-auth key.
func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
func (b *LocalBackend) TailnetLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return "", errNetworkLockNotActive
return "", errTailnetLockNotActive
}
pub, priv, err := ed25519.GenerateKey(nil) // nil == crypto/rand
@@ -1126,22 +1253,32 @@ func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.N
return "", fmt.Errorf("signing failed: %w", err)
}
b.logf("Generated network-lock credential signature using %s", tkaKey.Public().CLIString())
b.logf("Generated tailnet-lock credential signature using %s", tkaKey.Public().CLIString())
return fmt.Sprintf("%s--TL%s-%s", preauthKey, tkaSuffixEncoder.EncodeToString(sig.Serialize()), tkaSuffixEncoder.EncodeToString(priv)), nil
}
// NetworkLockVerifySigningDeeplink asks the authority to verify the given deeplink
// Deprecated: use [LocalBackend.TailnetLockWrapPreauthKey] instead.
func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
return b.TailnetLockWrapPreauthKey(preauthKey, tkaKey)
}
// TailnetLockVerifySigningDeeplink asks the authority to verify the given deeplink
// URL. See the comment for ValidateDeeplink for details.
func (b *LocalBackend) NetworkLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult {
func (b *LocalBackend) TailnetLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult {
b.mu.Lock()
defer b.mu.Unlock()
if b.tka == nil {
return tka.DeeplinkValidationResult{IsValid: false, Error: errNetworkLockNotActive.Error()}
return tka.DeeplinkValidationResult{IsValid: false, Error: errTailnetLockNotActive.Error()}
}
return b.tka.authority.ValidateDeeplink(url)
}
// Deprecated: use [LocalBackend.TailnetLockVerifySigningDeeplink] instead.
func (b *LocalBackend) NetworkLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult {
return b.TailnetLockVerifySigningDeeplink(url)
}
func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
p, err := nodeInfo.NodePublic.MarshalBinary()
if err != nil {
@@ -1173,6 +1310,8 @@ func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*ta
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// The response carries key signatures for every node in the tailnet.
ctx = httpbody.WithMaxSize(ctx, tkaMaxResponseBodySize)
req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/init/begin", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
@@ -1187,7 +1326,7 @@ func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*ta
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKAInitBeginResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1224,7 +1363,7 @@ func (b *LocalBackend) tkaInitFinish(ourNodeKey key.NodePublic, nks map[tailcfg.
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKAInitFinishResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1272,7 +1411,7 @@ func (b *LocalBackend) tkaFetchBootstrap(ourNodeKey key.NodePublic, head tka.AUM
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKABootstrapResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1302,6 +1441,8 @@ func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncO
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// The response carries AUMs the control plane believes we're missing.
ctx = httpbody.WithMaxSize(ctx, tkaMaxResponseBodySize)
req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/sync/offer", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
@@ -1316,7 +1457,7 @@ func (b *LocalBackend) tkaDoSyncOffer(ourNodeKey key.NodePublic, offer tka.SyncO
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKASyncOfferResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1365,7 +1506,7 @@ func (b *LocalBackend) tkaDoSyncSend(ourNodeKey key.NodePublic, head tka.AUMHash
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKASyncSendResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 10 * 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1407,7 +1548,7 @@ func (b *LocalBackend) tkaDoDisablement(ourNodeKey key.NodePublic, head tka.AUMH
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKADisableResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1443,7 +1584,7 @@ func (b *LocalBackend) tkaSubmitSignature(ourNodeKey key.NodePublic, sig tkatype
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKASubmitSignatureResponse)
err = json.NewDecoder(&io.LimitedReader{R: res.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1464,6 +1605,8 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// The response carries every signature made with the key.
ctx = httpbody.WithMaxSize(ctx, tkaMaxResponseBodySize)
req, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/affected-sigs", &encodedReq)
if err != nil {
@@ -1479,7 +1622,7 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
return nil, fmt.Errorf("request returned (%d): %s", resp.StatusCode, string(body))
}
a := new(tailcfg.TKASignaturesUsingKeyResponse)
err = json.NewDecoder(&io.LimitedReader{R: resp.Body, N: 1024 * 1024}).Decode(a)
err = json.NewDecoder(resp.Body).Decode(a)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
@@ -1487,3 +1630,24 @@ func (b *LocalBackend) tkaReadAffectedSigs(ourNodeKey key.NodePublic, key tkatyp
return a, nil
}
// LocalBackendWithTKAForTest creates a LocalBackend with an initialized TKA
// state for testing tailnet lock from the feature/tailnetlock package. Will be
// removed when tailnet lock is fully moved to its own package. Do not use this
// from any other package.
func LocalBackendWithTKAForTest(chonk tka.CompactableChonk, tka *tka.Authority) *LocalBackend {
testenv.AssertInTest()
var state *tkaState
if tka != nil {
state = &tkaState{
authority: tka,
storage: chonk,
}
}
return &LocalBackend{
store: &mem.Store{},
logf: logger.Discard,
tka: state,
}
}
+12 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_tailnetlock
@@ -26,6 +26,15 @@ func (b *LocalBackend) tkaSyncIfNeeded(nm *netmap.NetworkMap, prefs ipn.PrefsVie
func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {}
func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus {
return &ipnstate.NetworkLockStatus{Enabled: false}
func (b *LocalBackend) tkaFilterDeltaMutsLocked(muts []netmap.NodeMutation) []netmap.NodeMutation {
return muts
}
func (b *LocalBackend) TailnetLockStatus() *ipnstate.TailnetLockStatus {
return &ipnstate.TailnetLockStatus{Enabled: false}
}
// Deprecated: use [LocalBackend.TailnetLockStatus] instead.
func (b *LocalBackend) NetworkLockStatus() *ipnstate.TailnetLockStatus {
return b.TailnetLockStatus()
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"errors"
"fmt"
"log"
"net/netip"
"strings"
"sync"
"go4.org/netipx"
"tailscale.com/envknob"
"tailscale.com/net/tsaddr"
"tailscale.com/util/clientmetric"
)
// v4BroadcastAddr is 255.255.255.255, the limited broadcast address.
var v4BroadcastAddr = netip.AddrFrom4([4]byte{255, 255, 255, 255})
// viaAdditionsEnv is the name of an environment variable that can be set to
// override the 4via6 host-scoped address restrictions. Only use this if you are
// certain that there is not a better solution (e.g. perhaps tailscale serve,
// possibly using a Service). Use of this feature will have security
// implications: ensure that your ACLs/Grants are written appropriately.
// The value of the environment variable is a comma separated list of IPv4
// ranges (two IPv4 addresses separated by a hyphen), IPv4 prefixes (CIDR
// notation), or IPv4 addresses. Spaces are permitted around the commas.
const viaAdditionsEnv = "TS_4VIA6_ALLOW_LOCAL"
var viaTargetAdditions = sync.OnceValue(func() *netipx.IPSet {
v, err := generateViaTargetAdditions(envknob.String(viaAdditionsEnv))
if err != nil {
log.Print(err)
}
if v != nil {
has4via6AdditionsMetric := clientmetric.NewGauge("4via6_allow_local")
has4via6AdditionsMetric.Set(1)
}
return v
})
func generateViaTargetAdditions(rawAdditions string) (*netipx.IPSet, error) {
rawAdditions = strings.TrimSpace(rawAdditions)
if rawAdditions == "" {
return nil, nil
}
var ips netipx.IPSetBuilder
rawAddrs := strings.Split(rawAdditions, ",")
errs := make([]error, 0, len(rawAddrs))
for _, rawAddr := range rawAddrs {
rawAddr = strings.TrimSpace(rawAddr)
if rawAddr == "" {
continue
}
// Try parsing as a range
if ipRange, err := netipx.ParseIPRange(rawAddr); err == nil {
if !ipRange.From().Is4() || !ipRange.To().Is4() {
errs = append(errs, fmt.Errorf("rejecting %v range %v: not IPv4", viaAdditionsEnv, ipRange))
continue
}
ips.AddRange(ipRange)
continue
}
// Try parsing as a prefix
if prefix, err := netip.ParsePrefix(rawAddr); err == nil {
if !prefix.Addr().Is4() {
errs = append(errs, fmt.Errorf("rejecting %v prefix %v: not IPv4", viaAdditionsEnv, prefix))
continue
}
ips.AddPrefix(prefix)
continue
}
// Try parsing as an individual IP
if addr, err := netip.ParseAddr(rawAddr); err == nil {
if !addr.Is4() {
errs = append(errs, fmt.Errorf("rejecting %v address %v: not IPv4", viaAdditionsEnv, addr))
continue
}
ips.Add(addr)
continue
}
errs = append(errs, fmt.Errorf("rejecting %v value %q: not a range, prefix, or address", viaAdditionsEnv, rawAddr))
}
ipSet, err := ips.IPSet()
if err != nil {
errs = append(errs, err)
}
return ipSet, errors.Join(errs...)
}
// viaTargetAllowed reports whether ip may be forwarded to after unmapping a
// 4via6 destination. The packet filter only sees the outer via address, so
// this is the sole check on the embedded IPv4 target.
func viaTargetAllowed(ip netip.Addr) bool {
if !ip.Is4() {
return false // UnmapVia only returns IPv4
}
if additions := viaTargetAdditions(); additions != nil {
// Permit unusual configurations to use host-scoped IP addresses as
// 4via6 destinations when provided in an envknob.
if additions.Contains(ip) {
return true
}
}
if ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() || ip == v4BroadcastAddr {
return false
}
if tsaddr.IsTailscaleIP(ip) {
// A CGNAT-range target may route to tailscale0 or to a site LAN
// depending on the tailnet and OS (see shouldUseOneCGNATRoute);
// it's hard to detect when forwarding would be OK, so deny always
return false
}
if ip.IsLinkLocalUnicast() {
return false
}
return true
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"log"
"runtime"
"time"
"tailscale.com/tstime"
)
// deadlockProbeDelay is how long a watched call must be in flight before we
// start probing locks to check for a deadlock. Calls that complete sooner do
// not trigger any probing.
const deadlockProbeDelay = 5 * time.Second
// deadlockTimeout is how long the lock-probing goroutine is allowed to run
// before we declare a deadlock and panic with goroutine stacks. That is, it's
// the maximum total time we allow any of the probed locks to be held.
const deadlockTimeout = 30 * time.Second
// CheckDeadlocks schedules a delayed deadlock probe and returns a function to
// call when the operation being watched is done. Callers typically use it as
// "defer b.CheckDeadlocks()()" to bracket a region of code that should not
// take more than [deadlockProbeDelay].
//
// This is a backstop for detecting and debugging deadlocks in the process, replacing
// the earlier watchdogEngine removed in 2b338dd6a8dbd.
func (b *LocalBackend) CheckDeadlocks() (done func()) {
// Bump the in-flight count. If a watched region is already open, the
// probe timer is already armed, so the bump is all we need to do: the
// matching doneDeadlockCheck will decrement when this caller returns and
// only the last one out will stop the timer.
if b.deadlockChecksInFlight.Add(1) != 1 {
return b.doneDeadlockCheck
}
// Fast path to avoid the deadlockTimerMu+Timer.Reset cost when
// CheckDeadlocks is called many times per second by non-overlapping
// callers: re-arm the probe timer at most once per wall-clock second.
// We use a unix-seconds timestamp (+1 so 0 can mean "never") and a CAS
// so that only one caller per second proceeds to touch the timer; the
// rest return early.
nowUnix := tstime.DefaultClock{Clock: b.Clock()}.Now().Unix() + 1
lastUnix := b.lastDeadlockCheckUnix.Load()
if lastUnix == nowUnix || !b.lastDeadlockCheckUnix.CompareAndSwap(lastUnix, nowUnix) {
return b.doneDeadlockCheck
}
// Slow path: (re)arm the probe timer. Lazily create it on first use.
b.deadlockTimerMu.Lock()
defer b.deadlockTimerMu.Unlock()
t := b.deadlockProbeTimer
if t == nil {
t = time.AfterFunc(deadlockProbeDelay, b.runDeadlockProbe)
b.deadlockProbeTimer = t
} else {
t.Reset(deadlockProbeDelay)
}
return b.doneDeadlockCheck
}
func (b *LocalBackend) doneDeadlockCheck() {
switch n := b.deadlockChecksInFlight.Add(-1); {
case n > 0:
return
case n < 0:
panic("ipnlocal: doneDeadlockCheck called without matching CheckDeadlocks")
}
b.deadlockTimerMu.Lock()
defer b.deadlockTimerMu.Unlock()
if b.deadlockProbeTimer == nil {
return
}
b.deadlockProbeTimer.Stop()
}
func (b *LocalBackend) runDeadlockProbe() {
b.deadlockTimerMu.Lock()
defer b.deadlockTimerMu.Unlock()
if b.deadlockChecksInFlight.Load() == 0 {
return
}
t := b.deadlockTimer
if t == nil {
t = time.AfterFunc(deadlockTimeout, b.reportDeadlock)
b.deadlockTimer = t
} else {
t.Reset(deadlockTimeout)
}
defer t.Stop()
b.probeLocks()
}
func (b *LocalBackend) probeLocks() {
b.probeLocalBackendLock()
sys := b.sys
if sys == nil {
return
}
if bus, ok := sys.Bus.GetOK(); ok && bus != nil {
bus.ProbeLocks()
}
if dialer, ok := sys.Dialer.GetOK(); ok && dialer != nil {
dialer.ProbeLocks()
}
if dm, ok := sys.DNSManager.GetOK(); ok && dm != nil {
dm.ProbeLocks()
}
if e, ok := sys.Engine.GetOK(); ok && e != nil {
e.ProbeLocks()
}
if nm, ok := sys.NetMon.GetOK(); ok && nm != nil {
nm.ProbeLocks()
}
if mc, ok := sys.MagicSock.GetOK(); ok && mc != nil {
mc.ProbeLocks()
}
if tun, ok := sys.Tun.GetOK(); ok && tun != nil {
tun.ProbeLocks()
}
if ht, ok := sys.HealthTracker.GetOK(); ok && ht != nil {
ht.ProbeLocks()
}
}
func (b *LocalBackend) probeLocalBackendLock() {
b.mu.Lock()
defer b.mu.Unlock()
}
func (b *LocalBackend) reportDeadlock() {
logf := b.logf
if logf == nil {
logf = log.Printf
}
logf("ipnlocal watchdog goroutine stacks:\n%s", goroutineStacks())
panic("ipnlocal: watchdog timeout")
}
func goroutineStacks() []byte {
buf := make([]byte, 256<<10)
for {
n := runtime.Stack(buf, true)
if n < len(buf) {
return buf[:n]
}
buf = make([]byte, 2*len(buf))
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !ts_omit_webclient
//go:build !android && !ts_omit_webclient
package ipnlocal
@@ -173,7 +173,7 @@ func (b *LocalBackend) waitWebClientAuthURL(ctx context.Context, id string, src
// one to be completed, based on the presence or absence of the
// provided id value.
func (b *LocalBackend) doWebClientNoiseRequest(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
nm := b.NetMap()
nm := b.NetMapNoPeers()
if nm == nil || !nm.SelfNode.Valid() {
return nil, errors.New("[unexpected] no self node")
}
+2 -2
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android || ts_omit_webclient
//go:build android || ts_omit_webclient
package ipnlocal
+55 -20
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package ipnstate captures the entire state of the Tailscale network.
@@ -18,9 +18,9 @@ import (
"time"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/version"
@@ -74,6 +74,9 @@ type Status struct {
// trailing periods, and without any "_acme-challenge." prefix.
CertDomains []string
// ExtraRecords contains extra DNS records to add to the DNS resolver.
ExtraRecords []tailcfg.DNSRecord
// Peer is the state of each peer, keyed by each peer's current public key.
Peer map[key.NodePublic]*PeerStatus
@@ -87,7 +90,7 @@ type Status struct {
ClientVersion *tailcfg.ClientVersion
}
// TKAKey describes a key trusted by network lock.
// TKAKey describes a key trusted by tailnet lock.
type TKAKey struct {
Kind string
Key key.NLPublic
@@ -95,7 +98,7 @@ type TKAKey struct {
Votes uint
}
// TKAPeer describes a peer and its network lock details.
// TKAPeer describes a peer and its tailnet lock details.
type TKAPeer struct {
Name string // DNS
ID tailcfg.NodeID
@@ -105,18 +108,18 @@ type TKAPeer struct {
NodeKeySignature tka.NodeKeySignature
}
// NetworkLockStatus represents whether network-lock is enabled,
// TailnetLockStatus represents whether tailnet-lock is enabled,
// along with details about the locally-known state of the tailnet
// key authority.
type NetworkLockStatus struct {
// Enabled is true if network lock is enabled.
type TailnetLockStatus struct {
// Enabled is true if tailnet lock is enabled.
Enabled bool
// Head describes the AUM hash of the leaf AUM. Head is nil
// if network lock is not enabled.
// if tailnet lock is not enabled.
Head *[32]byte
// PublicKey describes the node's network-lock public key.
// PublicKey describes the node's tailnet-lock public key.
// It may be zero if the node has not logged in.
PublicKey key.NLPublic
@@ -124,18 +127,18 @@ type NetworkLockStatus struct {
// populated if the node is not operating (i.e. waiting for a login).
NodeKey *key.NodePublic
// NodeKeySigned is true if our node is authorized by network-lock.
// NodeKeySigned is true if our node is authorized by tailnet-lock.
NodeKeySigned bool
// NodeKeySignature is the current signature of this node's key.
NodeKeySignature *tka.NodeKeySignature
// TrustedKeys describes the keys currently trusted to make changes
// to network-lock.
// to tailnet-lock.
TrustedKeys []TKAKey
// VisiblePeers describes peers which are visible in the netmap that
// have valid Tailnet Lock signatures signatures.
// have valid Tailnet Lock signatures.
VisiblePeers []*TKAPeer
// FilteredPeers describes peers which were removed from the netmap
@@ -143,14 +146,17 @@ type NetworkLockStatus struct {
// checks.
FilteredPeers []*TKAPeer
// StateID is a nonce associated with the network lock authority,
// StateID is a nonce associated with the tailnet lock authority,
// generated upon enablement. This field is not populated if the
// network lock is disabled.
// tailnet lock is disabled.
StateID uint64
}
// NetworkLockUpdate describes a change to network-lock state.
type NetworkLockUpdate struct {
// Deprecated: use [TailnetLockStatus] instead.
type NetworkLockStatus = TailnetLockStatus
// TailnetLockUpdate describes a change to tailnet-lock state.
type TailnetLockUpdate struct {
Hash [32]byte
Change string // values of tka.AUMKind.String()
@@ -159,11 +165,18 @@ type NetworkLockUpdate struct {
Raw []byte
}
// Deprecated: use [TailnetLockUpdate] instead.
type NetworkLockUpdate = TailnetLockUpdate
// TailnetStatus is information about a Tailscale network ("tailnet").
type TailnetStatus struct {
// Name is the name of the network that's currently in use.
Name string
// StableID is the stable, unique identifier of the tailnet, as used to
// identify the tailnet in the Tailscale API.
StableID tailcfg.StableTailnetID
// MagicDNSSuffix is the network's MagicDNS suffix for nodes
// in the network such as "userfoo.tailscale.net".
// There are no surrounding dots.
@@ -224,6 +237,7 @@ type PeerStatusLite struct {
// inconsistencies or lost data in the peer status.
type PeerStatus struct {
ID tailcfg.StableNodeID
NodeID tailcfg.NodeID
PublicKey key.NodePublic
HostName string // HostInfo's Hostname (not a DNS name or necessarily unique)
@@ -293,7 +307,7 @@ type PeerStatus struct {
// Deprecated: use CapMap instead. See https://github.com/tailscale/tailscale/issues/11508
// Every value is Capabilities is also a key in CapMap, even if it
// has no values in that map.
Capabilities []tailcfg.NodeCapability `json:",omitempty"`
Capabilities []nodecap.Cap `json:",omitempty"`
// CapMap is a map of capabilities to their values.
CapMap tailcfg.NodeCapMap `json:",omitempty"`
@@ -347,10 +361,28 @@ const (
)
// HasCap reports whether ps has the given capability.
func (ps *PeerStatus) HasCap(cap tailcfg.NodeCapability) bool {
func (ps *PeerStatus) HasCap(cap nodecap.Cap) bool {
return ps.CapMap.Contains(cap)
}
// IsRouter reports whether ps describes a router:
// a node that routes addresses besides its own.
// Examples: an exit node, a subnet router, an app connector, etc.
// It is the analogue of [tailcfg.Node.IsRouter].
func (ps *PeerStatus) IsRouter() bool {
// TODO(sfllaw): Keep this aligned with dbx.Node.IsSubnetRouter.
if ps.AllowedIPs == nil {
return false
}
for _, r := range ps.AllowedIPs.All() {
if !r.IsSingleIP() || !slices.Contains(ps.TailscaleIPs, r.Addr()) {
return true
}
}
return false
}
// IsTagged reports whether ps is tagged.
func (ps *PeerStatus) IsTagged() bool {
return ps.Tags != nil && ps.Tags.Len() > 0
@@ -444,6 +476,9 @@ func (sb *StatusBuilder) AddPeer(peer key.NodePublic, st *PeerStatus) {
if v := st.ID; v != "" {
e.ID = v
}
if v := st.NodeID; v != 0 {
e.NodeID = v
}
if v := st.HostName; v != "" {
e.HostName = v
}
@@ -535,7 +570,7 @@ func (sb *StatusBuilder) AddPeer(peer key.NodePublic, st *PeerStatus) {
e.Expired = true
}
if t := st.KeyExpiry; t != nil {
e.KeyExpiry = ptr.To(*t)
e.KeyExpiry = new(*t)
}
if v := st.CapMap; v != nil {
e.CapMap = v
@@ -715,7 +750,7 @@ type PingResult struct {
// DERPRegionID is non-zero DERP region ID if DERP was used.
// It is not currently set for TSMP pings.
DERPRegionID int
DERPRegionID tailcfg.DERPRegionID
// DERPRegionCode is the three-letter region code
// corresponding to DERPRegionID.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
+13 -6
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !js && !ts_omit_acme
@@ -6,12 +6,15 @@
package localapi
import (
"errors"
"fmt"
"maps"
"net/http"
"strings"
"time"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tsweb"
)
func init() {
@@ -39,11 +42,15 @@ func (h *Handler) serveCert(w http.ResponseWriter, r *http.Request) {
}
pair, err := h.b.GetCertPEMWithValidity(r.Context(), domain, minValidity)
if err != nil {
// TODO(bradfitz): 500 is a little lazy here. The errors returned from
// GetCertPEM (and everywhere) should carry info info to get whether
// they're 400 vs 403 vs 500 at minimum. And then we should have helpers
// (in tsweb probably) to return an error that looks at the error value
// to determine the HTTP status code.
if hs, ok := errors.AsType[tsweb.HTTPStatuser](err); ok {
resp := hs.HTTPStatus()
maps.Copy(w.Header(), resp.Header)
http.Error(w, resp.Msg, resp.Code)
return
}
// TODO(bradfitz): 500 is a little lazy. Other errors from GetCertPEM
// should also implement tsweb.HTTPStatuser (400 vs 403 vs 500 vs …)
// rather than falling through here.
http.Error(w, fmt.Sprint(err), 500)
return
}
+106 -12
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debug
@@ -6,8 +6,10 @@
package localapi
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -23,6 +25,7 @@ import (
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
"tailscale.com/util/httpm"
@@ -35,6 +38,7 @@ func init() {
Register("dev-set-state-store", (*Handler).serveDevSetStateStore)
Register("debug-bus-events", (*Handler).serveDebugBusEvents)
Register("debug-bus-graph", (*Handler).serveEventBusGraph)
Register("debug-bus-queues", (*Handler).serveDebugBusQueues)
Register("debug-derp-region", (*Handler).serveDebugDERPRegion)
Register("debug-dial-types", (*Handler).serveDebugDialTypes)
Register("debug-log", (*Handler).serveDebugLog)
@@ -140,14 +144,11 @@ func (h *Handler) serveDebugDialTypes(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
for _, dialer := range dialers {
dialer := dialer // loop capture
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
conn, err := dialer.dial(ctx, network, addr)
results <- result{dialer.name, conn, err}
}()
})
}
wg.Wait()
@@ -200,8 +201,6 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
break
}
h.b.DebugNotify(n)
case "notify-last-netmap":
h.b.DebugNotifyLastNetMap()
case "break-tcp-conns":
err = h.b.DebugBreakTCPConns()
case "break-derp-conns":
@@ -218,7 +217,7 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
case "pick-new-derp":
err = h.b.DebugPickNewDERP()
case "force-prefer-derp":
var n int
var n tailcfg.DERPRegionID
err = json.NewDecoder(r.Body).Decode(&n)
if err != nil {
break
@@ -233,8 +232,39 @@ func (h *Handler) serveDebug(w http.ResponseWriter, r *http.Request) {
if err == nil {
return
}
case "peer-disco-keys":
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(h.b.DebugPeerDiscoKeys())
if err == nil {
return
}
case "rotate-disco-key":
err = h.b.DebugRotateDiscoKey()
case "statedir":
root := h.b.TailscaleVarRoot()
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(root)
if err == nil {
return
}
case "clear-netmap-cache":
h.b.ClearNetmapCache(r.Context())
case "current-netmap":
// Return the current netmap (with peers populated) as JSON. This
// is a debug-only path: the netmap.NetworkMap shape is an
// internal type and may change without notice. Production
// callers should fetch the narrower bits they need via their
// own LocalAPI methods instead.
nm := h.b.NetMapWithPeers()
if nm == nil {
err = errors.New("no netmap")
break
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(nm)
if err == nil {
return
}
case "":
err = fmt.Errorf("missing parameter 'action'")
default:
@@ -253,6 +283,13 @@ func (h *Handler) serveDevSetStateStore(w http.ResponseWriter, r *http.Request)
http.Error(w, "debug access denied", http.StatusForbidden)
return
}
// state keys are otherwise gated by their own handlers, e.g. serve-config
// requires a local admin for Unix-socket targets; writing a _serve/<profile>
// key here would bypass that, so require a local admin too
if !h.Actor.IsLocalAdmin(h.b.OperatorUserID()) {
http.Error(w, "dev-set-state-store access denied; must be a local admin", http.StatusUnauthorized)
return
}
if r.Method != httpm.POST {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
@@ -270,7 +307,7 @@ func (h *Handler) serveDebugPacketFilterRules(w http.ResponseWriter, r *http.Req
http.Error(w, "debug access denied", http.StatusForbidden)
return
}
nm := h.b.NetMap()
nm := h.b.NetMapNoPeers()
if nm == nil {
http.Error(w, "no netmap", http.StatusNotFound)
return
@@ -287,7 +324,7 @@ func (h *Handler) serveDebugPacketFilterMatches(w http.ResponseWriter, r *http.R
http.Error(w, "debug access denied", http.StatusForbidden)
return
}
nm := h.b.NetMap()
nm := h.b.NetMapNoPeers()
if nm == nil {
http.Error(w, "no netmap", http.StatusNotFound)
return
@@ -424,12 +461,68 @@ func (h *Handler) serveEventBusGraph(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(topics)
}
func (h *Handler) serveDebugBusQueues(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.GET {
http.Error(w, "GET required", http.StatusMethodNotAllowed)
return
}
bus, ok := h.LocalBackend().Sys().Bus.GetOK()
if !ok {
http.Error(w, "event bus not running", http.StatusPreconditionFailed)
return
}
debugger := bus.Debugger()
type clientQueue struct {
Name string `json:"name"`
SubscribeDepth int `json:"subscribeDepth"`
SubscribeTypes []string `json:"subscribeTypes,omitempty"`
PublishTypes []string `json:"publishTypes,omitempty"`
}
publishQueue := debugger.PublishQueue()
clients := debugger.Clients()
result := struct {
PublishQueueDepth int `json:"publishQueueDepth"`
Clients []clientQueue `json:"clients"`
}{
PublishQueueDepth: len(publishQueue),
}
for _, c := range clients {
sq := debugger.SubscribeQueue(c)
cq := clientQueue{
Name: c.Name(),
SubscribeDepth: len(sq),
}
for _, t := range debugger.SubscribeTypes(c) {
cq.SubscribeTypes = append(cq.SubscribeTypes, t.String())
}
for _, t := range debugger.PublishTypes(c) {
cq.PublishTypes = append(cq.PublishTypes, t.String())
}
result.Clients = append(result.Clients, cq)
}
slices.SortFunc(result.Clients, func(a, b clientQueue) int {
if a.SubscribeDepth != b.SubscribeDepth {
return b.SubscribeDepth - a.SubscribeDepth
}
return cmp.Compare(a.Name, b.Name)
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func (h *Handler) serveDebugLog(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasLogTail {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if !h.PermitRead {
if !h.PermitWrite {
http.Error(w, "debug-log access denied", http.StatusForbidden)
return
}
@@ -472,6 +565,7 @@ func (h *Handler) serveDebugLog(w http.ResponseWriter, r *http.Request) {
func (h *Handler) serveDebugOptionalFeatures(w http.ResponseWriter, r *http.Request) {
of := &apitype.OptionalFeatures{
Features: feature.Registered(),
Disabled: feature.EnvDisabled(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(of)
+22 -11
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debug
@@ -15,6 +15,7 @@ import (
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"tailscale.com/derp/derphttp"
@@ -22,11 +23,28 @@ import (
"tailscale.com/net/netaddr"
"tailscale.com/net/netns"
"tailscale.com/net/stun"
"tailscale.com/net/tlsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/nettype"
)
// tlsConfigForNode builds a *tls.Config for connecting to a DERP node,
// mirroring the logic in derphttp.Client.tlsClient so that sha256-raw cert
// pinning and domain-fronting CertName values are handled correctly.
func tlsConfigForNode(node *tailcfg.DERPNode) *tls.Config {
conf := tlsdial.Config(nil, nil)
conf.ServerName = node.HostName
if node.CertName != "" {
if suf, ok := strings.CutPrefix(node.CertName, "sha256-raw:"); ok {
tlsdial.SetConfigExpectedCertHash(conf, suf)
} else {
tlsdial.SetConfigExpectedCert(conf, node.CertName)
}
}
return conf
}
func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "debug access denied", http.StatusForbidden)
@@ -50,7 +68,7 @@ func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
}
regStr := r.FormValue("region")
var reg *tailcfg.DERPRegion
if id, err := strconv.Atoi(regStr); err == nil {
if id, err := tailcfg.ParseDERPRegionID(regStr); err == nil {
reg = dm.Regions[id]
} else {
for _, r := range dm.Regions {
@@ -100,9 +118,7 @@ func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
defer conn.Close()
// Upgrade to TLS and verify that works properly.
tlsConn := tls.Client(conn, &tls.Config{
ServerName: cmp.Or(derpNode.CertName, derpNode.HostName),
})
tlsConn := tls.Client(conn, tlsConfigForNode(derpNode))
if err := tlsConn.HandshakeContext(ctx); err != nil {
st.Errors = append(st.Errors, fmt.Sprintf("Error upgrading connection to node %q @ %q to TLS over IPv4: %v", derpNode.HostName, addr, err))
} else {
@@ -119,12 +135,7 @@ func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
defer conn.Close()
// Upgrade to TLS and verify that works properly.
tlsConn := tls.Client(conn, &tls.Config{
ServerName: cmp.Or(derpNode.CertName, derpNode.HostName),
// TODO(andrew-d): we should print more
// detailed failure information on if/why TLS
// verification fails
})
tlsConn := tls.Client(conn, tlsConfigForNode(derpNode))
if err := tlsConn.HandshakeContext(ctx); err != nil {
st.Errors = append(st.Errors, fmt.Sprintf("Error upgrading connection to node %q @ %q to TLS over IPv6: %v", derpNode.HostName, addr, err))
} else {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android || js
+258 -64
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package localapi contains the HTTP server handlers for tailscaled's API server.
@@ -7,6 +7,7 @@ package localapi
import (
"bytes"
"cmp"
"context"
"crypto/subtle"
"encoding/json"
"errors"
@@ -28,13 +29,13 @@ import (
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health/healthmsg"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
"tailscale.com/logtail"
"tailscale.com/net/neterror"
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
@@ -43,8 +44,8 @@ import (
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/types/ptr"
"tailscale.com/util/clientmetric"
"tailscale.com/util/def"
"tailscale.com/util/eventbus"
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
@@ -73,20 +74,25 @@ var handler = map[string]LocalAPIHandler{
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
// without a trailing slash:
"cert-domains": (*Handler).serveCertDomains,
"check-prefs": (*Handler).serveCheckPrefs,
"check-so-mark-in-use": (*Handler).serveCheckSOMarkInUse,
"derpmap": (*Handler).serveDERPMap,
"dns-config": (*Handler).serveDNSConfig,
"goroutines": (*Handler).serveGoroutines,
"login-interactive": (*Handler).serveLoginInteractive,
"logout": (*Handler).serveLogout,
"peer-by-id": (*Handler).servePeerByID,
"ping": (*Handler).servePing,
"prefs": (*Handler).servePrefs,
"reload-config": (*Handler).reloadConfig,
"reset-auth": (*Handler).serveResetAuth,
"services": (*Handler).serveServices,
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"shutdown": (*Handler).serveShutdown,
"start": (*Handler).serveStart,
"status": (*Handler).serveStatus,
"user-profile": (*Handler).serveUserProfile,
"whois": (*Handler).serveWhoIs,
}
@@ -99,9 +105,6 @@ func init() {
Register("check-udp-gro-forwarding", (*Handler).serveCheckUDPGROForwarding)
Register("set-udp-gro-forwarding", (*Handler).serveSetUDPGROForwarding)
}
if buildfeatures.HasUseExitNode && runtime.GOOS == "linux" {
Register("check-reverse-path-filtering", (*Handler).serveCheckReversePathFiltering)
}
if buildfeatures.HasClientMetrics {
Register("upload-client-metrics", (*Handler).serveUploadClientMetrics)
}
@@ -119,7 +122,7 @@ func init() {
Register("bugreport", (*Handler).serveBugReport)
Register("pprof", (*Handler).servePprof)
}
if buildfeatures.HasDebug || buildfeatures.HasServe {
if buildfeatures.HasIPNBus {
Register("watch-ipn-bus", (*Handler).serveWatchIPNBus)
}
if buildfeatures.HasDNS {
@@ -266,6 +269,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
defer h.b.CheckDeadlocks()()
if fn, route, ok := handlerForPath(r.URL.Path); ok {
h.logRequest(r.Method, route)
fn(h, w, r)
@@ -350,7 +354,7 @@ func (h *Handler) serveIDToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "id-token access denied", http.StatusForbidden)
return
}
nm := h.b.NetMap()
nm := h.b.NetMapNoPeers()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
@@ -400,7 +404,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
defer h.b.TryFlushLogs() // kick off upload after bugreport's done logging
logMarker := func() string {
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format("20060102150405Z"), rands.HexString(16))
return fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, h.clock.Now().UTC().Format(tstime.NumericDateTimeZ), rands.HexString(16))
}
if envknob.NoLogsNoSupport() {
logMarker = func() string { return "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled" }
@@ -420,7 +424,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
}
// Information about the current node from the netmap
if nm := h.b.NetMap(); nm != nil {
if nm := h.b.NetMapNoPeers(); nm != nil {
if self := nm.SelfNode; self.Valid() {
h.logf("user bugreport node info: nodeid=%q stableid=%q expiry=%q", self.ID(), self.StableID(), self.KeyExpiry().Format(time.RFC3339))
}
@@ -438,7 +442,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
h.logf.JSON(1, "UserBugReportOS", osdiag.SupportInfo(osdiag.LogSupportInfoReasonBugReport))
// Tailnet Lock details
st := h.b.NetworkLockStatus()
st := h.b.TailnetLockStatus()
if st.Enabled {
h.logf.JSON(1, "UserBugReportTailnetLockStatus", st)
if st.NodeKeySignature != nil {
@@ -446,7 +450,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
}
}
if defBool(r.URL.Query().Get("diagnose"), false) {
if def.Bool(r.URL.Query().Get("diagnose"), false) {
if f, ok := ipnlocal.HookDoctor.GetOk(); ok {
f(r.Context(), h.b, logger.WithPrefix(h.logf, "diag: "))
}
@@ -456,7 +460,7 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
// Nothing else to do if we're not in record mode; we wrote the marker
// above, so we can just finish our response now.
if !defBool(r.URL.Query().Get("record"), false) {
if !def.Bool(r.URL.Query().Get("record"), false) {
return
}
@@ -545,6 +549,8 @@ type localBackendWhoIsMethods interface {
WhoIs(string, netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
WhoIsNodeKey(key.NodePublic) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool)
PeerCaps(netip.Addr) tailcfg.PeerCapMap
PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap
PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap
}
func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request, b localBackendWhoIsMethods) {
@@ -592,7 +598,25 @@ func (h *Handler) serveWhoIsWithBackend(w http.ResponseWriter, r *http.Request,
UserProfile: &u, // always non-nil per WhoIsResponse contract
}
if n.Addresses().Len() > 0 {
res.CapMap = b.PeerCaps(n.Addresses().At(0).Addr())
src := n.Addresses().At(0).Addr()
switch {
case r.FormValue("svc_name") != "":
svcName := tailcfg.AsServiceName(r.FormValue("svc_name"))
if svcName == "" {
http.Error(w, "invalid svc_name", http.StatusBadRequest)
return
}
res.CapMap = b.PeerCapsForService(src, svcName)
case r.FormValue("dst_ip") != "":
svcAddr, err := netip.ParseAddr(r.FormValue("dst_ip"))
if err != nil {
http.Error(w, "invalid dst_ip", http.StatusBadRequest)
return
}
res.CapMap = b.PeerCapsForIP(src, svcAddr)
default:
res.CapMap = b.PeerCaps(src)
}
}
j, err := json.MarshalIndent(res, "", "\t")
if err != nil {
@@ -779,32 +803,6 @@ func (h *Handler) serveCheckSOMarkInUse(w http.ResponseWriter, r *http.Request)
})
}
func (h *Handler) serveCheckReversePathFiltering(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "reverse path filtering check access denied", http.StatusForbidden)
return
}
var warning string
state := h.b.Sys().NetMon.Get().InterfaceState()
warn, err := netutil.CheckReversePathFiltering(state)
if err == nil && len(warn) > 0 {
var msg strings.Builder
msg.WriteString(healthmsg.WarnExitNodeUsage + ":\n")
for _, w := range warn {
msg.WriteString("- " + w + "\n")
}
msg.WriteString(healthmsg.DisableRPFilter)
warning = msg.String()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Warning string
}{
Warning: warning,
})
}
func (h *Handler) serveCheckUDPGROForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "UDP GRO forwarding check access denied", http.StatusForbidden)
@@ -850,7 +848,7 @@ func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
var st *ipnstate.Status
if defBool(r.FormValue("peers"), true) {
if def.Bool(r.FormValue("peers"), true) {
st = h.b.Status()
} else {
st = h.b.StatusWithoutPeers()
@@ -874,8 +872,8 @@ func InUseOtherUserIPNStream(w http.ResponseWriter, r *http.Request, err error)
}
js, err := json.Marshal(&ipn.Notify{
Version: version.Long(),
State: ptr.To(ipn.InUseOtherUser),
ErrMessage: ptr.To(err.Error()),
State: new(ipn.InUseOtherUser),
ErrMessage: new(err.Error()),
})
if err != nil {
return false
@@ -899,12 +897,18 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
var mask ipn.NotifyWatchOpt
if s := r.FormValue("mask"); s != "" {
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
if err := mask.UnmarshalText([]byte(s)); err != nil {
http.Error(w, "bad mask", http.StatusBadRequest)
return
}
mask = ipn.NotifyWatchOpt(v)
}
if mask&ipn.NotifyInProcessNoDisconnect != 0 {
http.Error(w, "NotifyInProcessNoDisconnect is only valid for in-process IPN bus subscribers", http.StatusBadRequest)
return
}
if err := ipn.ValidateNotifyWatchOpt(mask); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
@@ -913,7 +917,9 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
h.b.WatchNotificationsAs(ctx, h.Actor, mask, f.Flush, func(roNotify *ipn.Notify) (keepGoing bool) {
err := enc.Encode(roNotify)
if err != nil {
h.logf("json.Encode: %v", err)
if !neterror.IsClosedPipeError(err) {
h.logf("json.Encode: %v", err)
}
return false
}
f.Flush()
@@ -1100,6 +1106,121 @@ func (h *Handler) serveDERPMap(w http.ResponseWriter, r *http.Request) {
e.Encode(h.b.DERPMap())
}
// serveCertDomains returns the list of DNS.CertDomains from the current
// netmap, or an empty list if no netmap has been received yet.
// The returned list is sorted in ascending order.
func (h *Handler) serveCertDomains(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "cert-domains access denied", http.StatusForbidden)
return
}
var domains []string
if nm := h.b.NetMapNoPeers(); nm != nil {
domains = slices.Clone(nm.DNS.CertDomains)
slices.Sort(domains)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(domains)
}
// serveDNSConfig returns the [tailcfg.DNSConfig] from the current netmap.
// It returns 503 if no netmap has been received yet.
func (h *Handler) serveDNSConfig(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "dns-config access denied", http.StatusForbidden)
return
}
nm := h.b.NetMapNoPeers()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", "\t")
e.Encode(nm.DNS)
}
// peerByIDBackend is the subset of [ipnlocal.LocalBackend] used by
// [Handler.servePeerByID]. It exists so the handler can be tested with a
// trivial mock without spinning up a full LocalBackend.
type peerByIDBackend interface {
PeerByID(tailcfg.NodeID) (tailcfg.NodeView, bool)
}
// servePeerByID returns the current full [tailcfg.Node] for the peer with the
// NodeID given in the "id" query parameter. It returns 404 if no such peer is
// in the current netmap.
//
// It is intended for clients that observed a peer-mutation signal (e.g.
// [ipn.Notify.PeerChangedPatch] or [ipn.Notify.PeersChanged]) and want the
// latest state of the affected node without having to apply the patch
// themselves.
func (h *Handler) servePeerByID(w http.ResponseWriter, r *http.Request) {
h.servePeerByIDWithBackend(w, r, h.b)
}
func (h *Handler) servePeerByIDWithBackend(w http.ResponseWriter, r *http.Request, b peerByIDBackend) {
if !h.PermitRead {
http.Error(w, "peer-by-id access denied", http.StatusForbidden)
return
}
idStr := r.FormValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid 'id' parameter", http.StatusBadRequest)
return
}
nv, ok := b.PeerByID(tailcfg.NodeID(id))
if !ok {
http.Error(w, "no peer with that NodeID", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", "\t")
e.Encode(nv.AsStruct())
}
// userProfileBackend is the subset of [ipnlocal.LocalBackend] used by
// [Handler.serveUserProfile]. It exists so the handler can be tested
// with a trivial mock without spinning up a full LocalBackend.
type userProfileBackend interface {
UserProfile(tailcfg.UserID) (tailcfg.UserProfileView, bool)
}
// serveUserProfile returns the current [tailcfg.UserProfile] for the User
// with the UserID given in the "id" query parameter, in O(1) time. It
// returns 404 if no such user is in the current netmap.
//
// It is the LocalAPI fallback for IPN-bus consumers that see a UserID
// referenced by a peer Node and want to resolve it to a UserProfile.
func (h *Handler) serveUserProfile(w http.ResponseWriter, r *http.Request) {
h.serveUserProfileWithBackend(w, r, h.b)
}
func (h *Handler) serveUserProfileWithBackend(w http.ResponseWriter, r *http.Request, b userProfileBackend) {
if !h.PermitRead {
http.Error(w, "user-profile access denied", http.StatusForbidden)
return
}
idStr := r.FormValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid 'id' parameter", http.StatusBadRequest)
return
}
uv, ok := b.UserProfile(tailcfg.UserID(id))
if !ok {
http.Error(w, "no user with that UserID", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", "\t")
e.Encode(uv.AsStruct())
}
// serveSetExpirySooner sets the expiry date on the current machine, specified
// by an `expiry` unix timestamp as POST or query param.
func (h *Handler) serveSetExpirySooner(w http.ResponseWriter, r *http.Request) {
@@ -1196,15 +1317,33 @@ func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing Dial-Host or Dial-Port header", http.StatusBadRequest)
return
}
network := cmp.Or(r.Header.Get("Dial-Network"), "tcp")
addr := net.JoinHostPort(hostStr, portStr)
// Check whether the resolved address is a Tailscale route.
// If not, tell the client to dial it directly so the connection
// comes from the calling user's UID rather than our root-owned daemon.
ipp, viaTailscale, err := h.b.Dialer().UserDialPlan(r.Context(), network, addr)
if err != nil {
http.Error(w, "resolve failure: "+err.Error(), http.StatusBadGateway)
return
}
if !viaTailscale {
w.Header().Set("Dial-Self", "true")
w.Header().Set("Dial-Addr", ipp.String())
w.WriteHeader(http.StatusOK)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "make request over HTTP/1", http.StatusBadRequest)
return
}
network := cmp.Or(r.Header.Get("Dial-Network"), "tcp")
addr := net.JoinHostPort(hostStr, portStr)
// Dial via Tailscale with the original hostname so UserDial can
// resolve all addresses and race across families (happy eyeballs).
outConn, err := h.b.Dialer().UserDial(r.Context(), network, addr)
if err != nil {
http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway)
@@ -1485,7 +1624,7 @@ func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing feature", http.StatusInternalServerError)
return
}
nm := h.b.NetMap()
nm := h.b.NetMapNoPeers()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
@@ -1521,17 +1660,6 @@ func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
}
}
func defBool(a string, def bool) bool {
if a == "" {
return def
}
v, err := strconv.ParseBool(a)
if err != nil {
return def
}
return v
}
// serveUpdateCheck returns the ClientVersion from Status, which contains
// information on whether an update is available, and if so, what version,
// *if* we support auto-updates on this platform. If we don't, this endpoint
@@ -1681,16 +1809,68 @@ func dnsMessageTypeForString(s string) (t dnsmessage.Type, err error) {
return 0, errors.New("unknown DNS message type: " + s)
}
// HookRouteCheckRefresh is the hook to request a refresh of the routecheck Report
// for this LocalBackend.
// It is used by serveSuggestExitNode to probe for reachable exit nodes
// before it makes a suggestion.
var HookRouteCheckRefresh feature.Hook[func(*ipnlocal.LocalBackend, context.Context, time.Duration) error]
// serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.
// If the probe query parameter is true,
// then a new routecheck report will be probed
// so that the suggested exit node isn’t based on stale reachability data.
// Combined with probe=true:
// if the timeout query parameter is 0, any probes will immediately timeout;
// if the timeout is positive, probes will take that duration before timing out;
// if the timeout is negative, probes will use the default routecheck timeout.
func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasUseExitNode {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if r.Method != httpm.GET {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
switch r.Method {
case httpm.GET:
// The GET method is inappropriate because it isn’t cacheable.
// However, we retain it for backwards compatibility.
//
// The original implementation used to only allow GET requests,
// but since this endpoint powers the `tailscale exit-node suggest` command
// whose results can change based on the shape of the network,
// it was never a proper GET method in the first place.
//
// Now that the suggestions are backed by routecheck
// and we provide the user the ability to trigger a probe,
// this is obviously a POST method because it has side-effects.
// However, we don’t want to break existing clients,
// so we silently support the old GET method
// without the extra query parameters.
//
// This is also why the default case returns a "want POST" error
// and not an "only POST allowed" like the other endpoints.
// We still accept GET requests but we don’t want them.
case httpm.POST:
if !def.Bool(r.FormValue("probe"), false) {
break
}
timeout := def.Duration(r.FormValue("timeout"), -1)
// Force routecheck to probe for a new report
// and use it to suggest an exit node.
routecheckRefresh := HookRouteCheckRefresh.GetOrNil()
if routecheckRefresh == nil {
break
}
if err := routecheckRefresh(h.b, r.Context(), timeout); err != nil {
WriteErrorJSON(w, err)
return
}
default:
// Discourage the GET method:
http.Error(w, "want POST", http.StatusMethodNotAllowed)
return
}
res, err := h.b.SuggestExitNode()
if err != nil {
WriteErrorJSON(w, err)
@@ -1735,6 +1915,20 @@ func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) {
eventbus.Publish[Shutdown](ec).Publish(Shutdown{})
}
func (h *Handler) serveServices(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.GET {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return
}
nm := h.b.NetMapNoPeers()
if nm == nil {
http.Error(w, "no netmap", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(nm.Services())
}
func (h *Handler) serveGetAppcRouteInfo(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasAppConnectors {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android && !js && !ts_omit_debug
+6 -5
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
@@ -53,7 +53,8 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
return
}
// require a local admin when setting a path handler
// require a local admin when setting a path handler or serving a Unix
// domain socket
// TODO: roll-up this Windows-specific check into either PermitWrite
// or a global admin escalation check.
if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
@@ -89,7 +90,7 @@ func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeC
if goos == "darwin" && version.IsSandboxedMacOS() {
return nil
}
if !configIn.HasPathHandler() {
if !configIn.HasPathHandler() && !configIn.IsServingUnixAny() {
return nil
}
if h.Actor.IsLocalAdmin(h.b.OperatorUserID()) {
@@ -97,9 +98,9 @@ func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeC
}
switch goos {
case "windows":
return errors.New("must be a Windows local admin to serve a path")
return errors.New("must be a Windows local admin to serve a path or Unix socket")
case "linux", "darwin", "illumos", "solaris":
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path or Unix socket")
default:
// We filter goos at the start of the func, this default case
// should never happen.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_syspolicy
+23 -23
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
@@ -43,7 +43,7 @@ func (h *Handler) serveTKAStatus(w http.ResponseWriter, r *http.Request) {
return
}
j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
j, err := json.MarshalIndent(h.b.TailnetLockStatus(), "", "\t")
if err != nil {
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
return
@@ -72,7 +72,7 @@ func (h *Handler) serveTKASign(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.b.NetworkLockSign(req.NodeKey, req.RotationPublic); err != nil {
if err := h.b.TailnetLockSign(req.NodeKey, req.RotationPublic); err != nil {
http.Error(w, "signing failed: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -101,17 +101,17 @@ func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) {
return
}
if !h.b.NetworkLockAllowed() {
if !h.b.TailnetLockAllowed() {
http.Error(w, "Tailnet Lock is not supported on your pricing plan", http.StatusForbidden)
return
}
if err := h.b.NetworkLockInit(req.Keys, req.DisablementValues, req.SupportDisablement); err != nil {
if err := h.b.TailnetLockInit(req.Keys, req.DisablementValues, req.SupportDisablement); err != nil {
http.Error(w, "initialization failed: "+err.Error(), http.StatusInternalServerError)
return
}
j, err := json.MarshalIndent(h.b.NetworkLockStatus(), "", "\t")
j, err := json.MarshalIndent(h.b.TailnetLockStatus(), "", "\t")
if err != nil {
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
return
@@ -122,7 +122,7 @@ func (h *Handler) serveTKAInit(w http.ResponseWriter, r *http.Request) {
func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
http.Error(w, "tailnet-lock modify access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
@@ -140,8 +140,8 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.b.NetworkLockModify(req.AddKeys, req.RemoveKeys); err != nil {
http.Error(w, "network-lock modify failed: "+err.Error(), http.StatusInternalServerError)
if err := h.b.TailnetLockModify(req.AddKeys, req.RemoveKeys); err != nil {
http.Error(w, "tailnet-lock modify failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(204)
@@ -149,7 +149,7 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
func (h *Handler) serveTKAWrapPreauthKey(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
http.Error(w, "tailnet-lock modify access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
@@ -172,7 +172,7 @@ func (h *Handler) serveTKAWrapPreauthKey(w http.ResponseWriter, r *http.Request)
return
}
wrappedKey, err := h.b.NetworkLockWrapPreauthKey(req.TSKey, priv)
wrappedKey, err := h.b.TailnetLockWrapPreauthKey(req.TSKey, priv)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -200,7 +200,7 @@ func (h *Handler) serveTKAVerifySigningDeeplink(w http.ResponseWriter, r *http.R
return
}
res := h.b.NetworkLockVerifySigningDeeplink(req.URL)
res := h.b.TailnetLockVerifySigningDeeplink(req.URL)
j, err := json.MarshalIndent(res, "", "\t")
if err != nil {
http.Error(w, "JSON encoding error", http.StatusInternalServerError)
@@ -212,7 +212,7 @@ func (h *Handler) serveTKAVerifySigningDeeplink(w http.ResponseWriter, r *http.R
func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
http.Error(w, "tailnet-lock modify access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
@@ -227,8 +227,8 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.b.NetworkLockDisable(secret); err != nil {
http.Error(w, "network-lock disable failed: "+err.Error(), http.StatusBadRequest)
if err := h.b.TailnetLockDisable(secret); err != nil {
http.Error(w, "tailnet-lock disable failed: "+err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
@@ -236,7 +236,7 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) {
func (h *Handler) serveTKALocalDisable(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
http.Error(w, "tailnet-lock modify access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
@@ -251,8 +251,8 @@ func (h *Handler) serveTKALocalDisable(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.b.NetworkLockForceLocalDisable(); err != nil {
http.Error(w, "network-lock local disable failed: "+err.Error(), http.StatusBadRequest)
if err := h.b.TailnetLockForceLocalDisable(); err != nil {
http.Error(w, "tailnet-lock local disable failed: "+err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
@@ -274,7 +274,7 @@ func (h *Handler) serveTKALog(w http.ResponseWriter, r *http.Request) {
limit = int(lm)
}
updates, err := h.b.NetworkLockLog(limit)
updates, err := h.b.TailnetLockLog(limit)
if err != nil {
http.Error(w, "reading log failed: "+err.Error(), http.StatusInternalServerError)
return
@@ -300,7 +300,7 @@ func (h *Handler) serveTKAAffectedSigs(w http.ResponseWriter, r *http.Request) {
return
}
sigs, err := h.b.NetworkLockAffectedSigs(keyID)
sigs, err := h.b.TailnetLockAffectedSigs(keyID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -343,7 +343,7 @@ func (h *Handler) serveTKAGenerateRecoveryAUM(w http.ResponseWriter, r *http.Req
}
}
res, err := h.b.NetworkLockGenerateRecoveryAUM(req.Keys, forkFrom)
res, err := h.b.TailnetLockGenerateRecoveryAUM(req.Keys, forkFrom)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -374,7 +374,7 @@ func (h *Handler) serveTKACosignRecoveryAUM(w http.ResponseWriter, r *http.Reque
return
}
res, err := h.b.NetworkLockCosignRecoveryAUM(&aum)
res, err := h.b.TailnetLockCosignRecoveryAUM(&aum)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -405,7 +405,7 @@ func (h *Handler) serveTKASubmitRecoveryAUM(w http.ResponseWriter, r *http.Reque
return
}
if err := h.b.NetworkLockSubmitRecoveryAUM(&aum); err != nil {
if err := h.b.TailnetLockSubmitRecoveryAUM(&aum); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+63 -37
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipn
@@ -17,6 +17,7 @@ import (
"runtime"
"slices"
"strings"
"time"
"tailscale.com/atomicfile"
"tailscale.com/drive"
@@ -129,7 +130,8 @@ type Prefs struct {
ExitNodeAllowLANAccess bool
// CorpDNS specifies whether to install the Tailscale network's
// DNS configuration, if it exists.
// DNS configuration, if it exists. It is the internal name for
// the "tailscale set --accept-dns=" flag.
CorpDNS bool
// RunSSH bool is whether this node should run an SSH
@@ -222,7 +224,7 @@ type Prefs struct {
// network to route Tailscale traffic back to the subnet relay
// machine.
//
// Linux-only.
// Linux and FreeBSD only.
NoSNAT bool
// NoStatefulFiltering specifies whether to apply stateful filtering when
@@ -276,6 +278,24 @@ type Prefs struct {
// Linux-only.
NetfilterKind string
// RemoteConfig, if true, delegates full remote control of this node's
// prefs and LocalAPI to the tailnet admin via the control plane. When
// enabled, the control server can read and edit any of this node's
// prefs at any time, and invoke any LocalAPI endpoint on this node,
// without any further local consent (no CLI or GUI confirmation).
//
// This is an alternative to Tailscale's default per-feature double
// opt-in model, in which both the tailnet admin and the local machine
// owner must agree to each individual setting change. RemoteConfig is
// a single client-side "I trust the tailnet admin" switch that hands
// over full remote management of this node.
//
// Only enable this when the tailnet admin owns the machine (e.g. a
// corporate fleet device) or the local user has explicitly delegated
// full control to the tailnet admin. Do NOT enable this on personal
// or BYOD devices where the tailnet admin is not fully trusted.
RemoteConfig bool
// DriveShares are the configured DriveShares, stored in increasing order
// by name.
DriveShares []*drive.Share
@@ -291,16 +311,6 @@ type Prefs struct {
// non-nil.
RelayServerStaticEndpoints []netip.AddrPort `json:",omitempty"`
// AllowSingleHosts was a legacy field that was always true
// for the past 4.5 years. It controlled whether Tailscale
// peers got /32 or /128 routes for each other.
// As of 2024-05-17 we're starting to ignore it, but to let
// people still downgrade Tailscale versions and not break
// all peer-to-peer networking we still write it to disk (as JSON)
// so it can be loaded back by old versions.
// TODO(bradfitz): delete this in 2025 sometime. See #12058.
AllowSingleHosts marshalAsTrueInJSON
// The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -331,13 +341,6 @@ func (au1 AutoUpdatePrefs) Equals(au2 AutoUpdatePrefs) bool {
ok1 == ok2
}
type marshalAsTrueInJSON struct{}
var trueJSON = []byte("true")
func (marshalAsTrueInJSON) MarshalJSON() ([]byte, error) { return trueJSON, nil }
func (*marshalAsTrueInJSON) UnmarshalJSON([]byte) error { return nil }
// AppConnectorPrefs are the app connector settings for the node agent.
type AppConnectorPrefs struct {
// Advertise specifies whether the app connector subsystem is advertising
@@ -383,6 +386,7 @@ type MaskedPrefs struct {
AppConnectorSet bool `json:",omitempty"`
PostureCheckingSet bool `json:",omitempty"`
NetfilterKindSet bool `json:",omitempty"`
RemoteConfigSet bool `json:",omitempty"`
DriveSharesSet bool `json:",omitempty"`
RelayServerPortSet bool `json:",omitempty"`
RelayServerStaticEndpointsSet bool `json:",omitzero"`
@@ -439,12 +443,11 @@ func applyPrefsEdits(src, dst reflect.Value, mask map[string]reflect.Value) {
func maskFields(v reflect.Value) map[string]reflect.Value {
mask := make(map[string]reflect.Value)
for i := range v.NumField() {
f := v.Type().Field(i).Name
if !strings.HasSuffix(f, "Set") {
for sf, fv := range v.Fields() {
if !strings.HasSuffix(sf.Name, "Set") {
continue
}
mask[strings.TrimSuffix(f, "Set")] = v.Field(i)
mask[strings.TrimSuffix(sf.Name, "Set")] = fv
}
return mask
}
@@ -570,6 +573,9 @@ func (p *Prefs) pretty(goos string) string {
if p.ShieldsUp {
sb.WriteString("shields=true ")
}
if p.RemoteConfig {
sb.WriteString("remoteconfig=true ")
}
if buildfeatures.HasUseExitNode {
if p.ExitNodeIP.IsValid() {
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeIP, p.ExitNodeAllowLANAccess)
@@ -692,6 +698,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.PostureChecking == p2.PostureChecking &&
slices.EqualFunc(p.DriveShares, p2.DriveShares, drive.SharesEqual) &&
p.NetfilterKind == p2.NetfilterKind &&
p.RemoteConfig == p2.RemoteConfig &&
compareUint16Ptrs(p.RelayServerPort, p2.RelayServerPort) &&
slices.Equal(p.RelayServerStaticEndpoints, p2.RelayServerStaticEndpoints)
}
@@ -845,22 +852,15 @@ func (p *Prefs) SetAdvertiseExitNode(runExit bool) {
// Tailscale IP.
func peerWithTailscaleIP(st *ipnstate.Status, ip netip.Addr) (ps *ipnstate.PeerStatus, ok bool) {
for _, ps := range st.Peer {
for _, ip2 := range ps.TailscaleIPs {
if ip == ip2 {
return ps, true
}
if slices.Contains(ps.TailscaleIPs, ip) {
return ps, true
}
}
return nil, false
}
func isRemoteIP(st *ipnstate.Status, ip netip.Addr) bool {
for _, selfIP := range st.TailscaleIPs {
if ip == selfIP {
return false
}
}
return true
return !slices.Contains(st.TailscaleIPs, ip)
}
// ClearExitNode sets the ExitNodeID and ExitNodeIP to their zero values.
@@ -880,10 +880,14 @@ func (e ExitNodeLocalIPError) Error() string {
return fmt.Sprintf("cannot use %s as an exit node as it is a local IP address to this machine", e.hostOrIP)
}
// exitNodeIPOfArg returns the IP address of the exit node based on the
// user-provided string.
func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
if s == "" {
return ip, os.ErrInvalid
}
// If the string is a valid IP address, that's the exit node.
ip, err = netip.ParseAddr(s)
if err == nil {
if !isRemoteIP(st, ip) {
@@ -902,10 +906,26 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
}
return ip, nil
}
// If the string is not a valid IP address, assume it's a hostname.
// Search the list of peers for a matching hostname.
if len(st.Peer) == 0 {
return ip, errors.New("cannot resolve exit node by hostname while Tailscale is starting up; " +
"please use its Tailscale IP address instead")
}
match := 0
for _, ps := range st.Peer {
baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix)
if !strings.EqualFold(s, baseName) && !strings.EqualFold(s, ps.DNSName) {
// Compare to the peer name in three forms:
//
// - base name ("example")
// - FQDN ("example.tail1234.ts.net.")
// - FQDN sans dot ("example.tail1234.ts.net", as returned by `tailscale exit-node list`
// and the admin console)
//
fqdn := ps.DNSName
baseName := dnsname.TrimSuffix(fqdn, st.MagicDNSSuffix)
fqdnSansDot := dnsname.TrimSuffix(fqdn, ".")
if !strings.EqualFold(s, baseName) && !strings.EqualFold(s, fqdn) && !strings.EqualFold(s, fqdnSansDot) {
continue
}
match++
@@ -919,7 +939,7 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
}
switch match {
case 0:
return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or unique node name", s)
return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or peer hostname", s)
case 1:
if !isRemoteIP(st, ip) {
return ip, ExitNodeLocalIPError{s}
@@ -1096,6 +1116,12 @@ type LoginProfile struct {
// ControlURL is the URL of the control server that this profile is logged
// into.
ControlURL string
// Created is when this profile was first added to this client. It is
// stamped once at profile creation and never changes. It is used to sort
// the profile list with newest first; profiles created before this field
// existed have a zero value and sort after all stamped profiles.
Created time.Time `json:",omitzero"`
}
// Equals reports whether p and p2 are equal.
+65 -19
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipn
@@ -17,6 +17,8 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tailcfg/peercap"
"tailscale.com/types/ipproto"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
@@ -139,7 +141,10 @@ type TCPPortHandler struct {
// It is mutually exclusive with TCPForward.
HTTP bool `json:",omitempty"`
// TCPForward is the IP:port to forward TCP connections to.
// TCPForward is the address to forward TCP connections to.
// It is either a host:port (e.g. "127.0.0.1:3128", "localhost:5432")
// or a Unix socket path prefixed with "unix:"
// (e.g. "unix:/var/run/app.sock" or "unix:relative.sock").
// Whether or not TLS is terminated by tailscaled depends on
// TerminateTLS.
//
@@ -168,7 +173,7 @@ type HTTPHandler struct {
Text string `json:",omitempty"` // plaintext to serve (primarily for testing)
AcceptAppCaps []tailcfg.PeerCapability `json:",omitempty"` // peer capabilities to forward in grant header, e.g. example.com/cap/mon
AcceptAppCaps []peercap.Cap `json:",omitempty"` // peer capabilities to forward in grant header, e.g. example.com/cap/mon
// Redirect, if not empty, is the target URL to redirect requests to.
// By default, we redirect with HTTP 302 (Found) status.
@@ -191,7 +196,7 @@ func (sc *ServeConfig) WebHandlerExists(svcName tailcfg.ServiceName, hp HostPort
}
// GetWebHandler returns the HTTPHandler for the given host:port and mount point.
// Returns nil if the handler does not exist.
// It returns nil if the handler does not exist.
func (sc *ServeConfig) GetWebHandler(svcName tailcfg.ServiceName, hp HostPort, mount string) *HTTPHandler {
if sc == nil {
return nil
@@ -264,6 +269,46 @@ func (sc *ServeConfig) HasPathHandler() bool {
return false
}
// IsServingUnixAny reports whether ServeConfig is serving Unix targets on any
// port or web handler.
func (sc *ServeConfig) IsServingUnixAny() bool {
if sc == nil {
return false
}
for _, fgSrvCfg := range sc.Foreground {
if fgSrvCfg.IsServingUnixAny() {
return true
}
}
for _, ph := range sc.TCP {
if strings.HasPrefix(ph.TCPForward, "unix:") {
return true
}
}
for _, web := range sc.Web {
for _, h := range web.Handlers {
if strings.HasPrefix(h.Proxy, "unix:") {
return true
}
}
}
for _, svcCfg := range sc.Services {
for _, ph := range svcCfg.TCP {
if strings.HasPrefix(ph.TCPForward, "unix:") {
return true
}
}
for _, web := range svcCfg.Web {
for _, h := range web.Handlers {
if strings.HasPrefix(h.Proxy, "unix:") {
return true
}
}
}
}
return false
}
// IsTCPForwardingAny reports whether ServeConfig is currently forwarding in
// TCPForward mode on any port. This is exclusive of Web/HTTPS serving.
func (sc *ServeConfig) IsTCPForwardingAny() bool {
@@ -422,10 +467,10 @@ func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uin
}
}
// SetTCPForwarding sets the fwdAddr (IP:port form) to which to forward
// connections from the given port. If terminateTLS is true, TLS connections
// are terminated with only the given host name permitted before passing them
// to the fwdAddr.
// SetTCPForwarding sets the fwdAddr to which to forward connections from the
// given port. fwdAddr is either an IP:port or "unix:/path/to/socket".
// If terminateTLS is true, TLS connections are terminated with only the given
// host name permitted before passing them to the fwdAddr.
//
// If proxyProtocol is non-zero, the corresponding PROXY protocol version
// header is sent before forwarding the connection.
@@ -443,10 +488,11 @@ func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTL
}
}
// SetTCPForwardingForService sets the fwdAddr (IP:port form) to which to
// forward connections from the given port on the service. If terminateTLS
// is true, TLS connections are terminated, with only the FQDN that corresponds
// to the given service being permitted, before passing them to the fwdAddr.
// SetTCPForwardingForService sets the fwdAddr to which to forward connections
// from the given port on the service. fwdAddr is either a host:port or
// "unix:/path" (absolute or relative). If terminateTLS is true, TLS connections
// are terminated, with only the FQDN that corresponds to the given service
// being permitted, before passing them to the fwdAddr.
func (sc *ServeConfig) SetTCPForwardingForService(port uint16, fwdAddr string, terminateTLS bool, svcName tailcfg.ServiceName, proxyProtocol int, magicDNSSuffix string) {
if sc == nil {
sc = new(ServeConfig)
@@ -608,10 +654,10 @@ func CheckFunnelAccess(port uint16, node *ipnstate.PeerStatus) error {
// NodeCanFunnel returns an error if the given node is not configured to allow
// for Tailscale Funnel usage.
func NodeCanFunnel(node *ipnstate.PeerStatus) error {
if !node.HasCap(tailcfg.CapabilityHTTPS) {
if !node.HasCap(nodecap.HTTPS) {
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.")
}
if !node.HasCap(tailcfg.NodeAttrFunnel) {
if !node.HasCap(nodecap.Funnel) {
return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/s/no-funnel.")
}
return nil
@@ -638,14 +684,14 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
return "", deny("")
}
u.RawQuery = ""
if u.String() != string(tailcfg.CapabilityFunnelPorts) {
if u.String() != string(nodecap.FunnelPorts) {
return "", deny("")
}
return portsStr, nil
}
for attr := range node.CapMap {
attr := string(attr)
if !strings.HasPrefix(attr, string(tailcfg.CapabilityFunnelPorts)) {
if !strings.HasPrefix(attr, string(nodecap.FunnelPorts)) {
continue
}
var err error
@@ -658,7 +704,7 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
if portsStr == "" {
for attr := range node.CapMap {
attr := string(attr)
if !strings.HasPrefix(attr, string(tailcfg.CapabilityFunnelPorts)) {
if !strings.HasPrefix(attr, string(nodecap.FunnelPorts)) {
continue
}
var err error
@@ -673,7 +719,7 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
return deny("")
}
wantedPortString := strconv.Itoa(int(wantedPort))
for _, ps := range strings.Split(portsStr, ",") {
for ps := range strings.SplitSeq(portsStr, ",") {
if ps == "" {
continue
}
@@ -785,7 +831,7 @@ func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultSch
return "", fmt.Errorf("invalid port %q", u.Port())
}
u.Host = fmt.Sprintf("%s:%d", u.Hostname(), port)
u.Host = net.JoinHostPort(u.Hostname(), strconv.Itoa(int(port)))
return u.String(), nil
}
+4 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipn
@@ -92,7 +92,9 @@ type StateStore interface {
// ReadState returns the bytes associated with ID. Returns (nil,
// ErrStateNotExist) if the ID doesn't have associated state.
ReadState(id StateKey) ([]byte, error)
// WriteState saves bs as the state associated with ID.
// WriteState saves bs as the state associated with ID. If bs is nil,
// the state associated with ID is deleted, and a subsequent ReadState
// for ID will return ErrStateNotExist.
//
// Callers should generally use the ipn.WriteState wrapper func
// instead, which only writes if the value is different from what's
+6 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package mem provides an in-memory ipn.StateStore implementation.
@@ -49,7 +49,11 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
if s.cache == nil {
s.cache = map[ipn.StateKey][]byte{}
}
s.cache[id] = bytes.Clone(bs)
if bs == nil {
delete(s.cache, id)
} else {
s.cache[id] = bytes.Clone(bs)
}
return nil
}
+6 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package store provides various implementation of ipn.StateStore.
@@ -214,7 +214,11 @@ func (s *FileStore) WriteState(id ipn.StateKey, bs []byte) error {
if bytes.Equal(s.cache[id], bs) {
return nil
}
s.cache[id] = bytes.Clone(bs)
if bs == nil {
delete(s.cache, id)
} else {
s.cache[id] = bytes.Clone(bs)
}
bs, err := json.MarshalIndent(s.cache, "", " ")
if err != nil {
return err