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