registering of split dns routes. Updates tailscale/corp#43680 Signed-off-by: Fran Bull <fran@tailscale.com>
1584 lines
52 KiB
Go
1584 lines
52 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
||
// SPDX-License-Identifier: BSD-3-Clause
|
||
|
||
package ipnlocal
|
||
|
||
import (
|
||
"cmp"
|
||
"context"
|
||
"iter"
|
||
"maps"
|
||
"net/netip"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
|
||
"go4.org/netipx"
|
||
"tailscale.com/appc"
|
||
"tailscale.com/envknob"
|
||
"tailscale.com/feature/buildfeatures"
|
||
"tailscale.com/ipn"
|
||
"tailscale.com/net/dns"
|
||
"tailscale.com/net/routecheck/peernode"
|
||
"tailscale.com/net/routemanager"
|
||
"tailscale.com/net/tsaddr"
|
||
"tailscale.com/syncs"
|
||
"tailscale.com/tailcfg"
|
||
"tailscale.com/types/dnstype"
|
||
"tailscale.com/types/key"
|
||
"tailscale.com/types/logger"
|
||
"tailscale.com/types/mapx"
|
||
"tailscale.com/types/netmap"
|
||
"tailscale.com/types/views"
|
||
"tailscale.com/util/dnsname"
|
||
"tailscale.com/util/eventbus"
|
||
"tailscale.com/util/mak"
|
||
"tailscale.com/util/set"
|
||
"tailscale.com/util/slicesx"
|
||
"tailscale.com/util/testenv"
|
||
"tailscale.com/wgengine/filter"
|
||
)
|
||
|
||
// nodeBackend is node-specific [LocalBackend] state. It is usually the current node.
|
||
//
|
||
// Its exported methods are safe for concurrent use, but the struct is not a snapshot of state at a given moment;
|
||
// its state can change between calls. For example, asking for the same value (e.g., netmap or prefs) twice
|
||
// may return different results. Returned values are immutable and safe for concurrent use.
|
||
//
|
||
// If both the [LocalBackend]'s internal mutex and the [nodeBackend] mutex must be held at the same time,
|
||
// the [LocalBackend] mutex must be acquired first. See the comment on the [LocalBackend] field for more details.
|
||
//
|
||
// Two pointers to different [nodeBackend] instances represent different local nodes.
|
||
// However, there's currently a bug where a new [nodeBackend] might not be created
|
||
// during an implicit node switch (see tailscale/corp#28014).
|
||
//
|
||
// In the future, we might want to include at least the following in this struct (in addition to the current fields).
|
||
// However, not everything should be exported or otherwise made available to the outside world (e.g. [ipnext] extensions,
|
||
// peer API handlers, etc.).
|
||
// - [ipn.State]: when the LocalBackend switches to a different [nodeBackend], it can update the state of the old one.
|
||
// - [ipn.LoginProfileView] and [ipn.Prefs]: we should update them when the [profileManager] reports changes to them.
|
||
// In the future, [profileManager] (and the corresponding methods of the [LocalBackend]) can be made optional,
|
||
// and something else could be used to set them once or update them as needed.
|
||
// - [tailcfg.HostinfoView]: it includes certain fields that are tied to the current profile/node/prefs. We should also
|
||
// update to build it once instead of mutating it in twelvety different places.
|
||
// - [filter.Filter] (normal and jailed, along with the filterHash): the nodeBackend could have a method to (re-)build
|
||
// the filter for the current netmap/prefs (see [LocalBackend.updateFilterLocked]), and it needs to track the current
|
||
// filters and their hash.
|
||
// - Fields related to a requested or required (re-)auth: authURL, authURLTime, authActor, keyExpired, etc.
|
||
// - [controlclient.Client]/[*controlclient.Auto]: the current control client. It is ties to a node identity.
|
||
// - [tkaState]: it is tied to the current profile / node.
|
||
// - Fields related to scheduled node expiration: nmExpiryTimer, numClientStatusCalls, [expiryManager].
|
||
//
|
||
// It should not include any fields used by specific features that don't belong in [LocalBackend].
|
||
// Even if they're tied to the local node, instead of moving them here, we should extract the entire feature
|
||
// into a separate package and have it install proper hooks.
|
||
type nodeBackend struct {
|
||
logf logger.Logf
|
||
|
||
ctx context.Context // canceled by [nodeBackend.shutdown]
|
||
ctxCancel context.CancelCauseFunc // cancels ctx
|
||
|
||
// filterAtomic is a stateful packet filter. Immutable once created, but can be
|
||
// replaced with a new one.
|
||
filterAtomic atomic.Pointer[filter.Filter]
|
||
|
||
// initialized once and immutable
|
||
eventClient *eventbus.Client
|
||
derpMapViewPub *eventbus.Publisher[tailcfg.DERPMapView]
|
||
|
||
// homeDERP lives here temporarily. as long as mapSession is short lived, we
|
||
// don't have a location delivering netmaps to local backend that knows our
|
||
// homeDERP hence why it is cached here for now.
|
||
// TODO(cmol): move this field into a refactored mapSession that is not
|
||
// short lived.
|
||
homeDERP atomic.Int64
|
||
|
||
// TODO(nickkhyl): maybe use sync.RWMutex?
|
||
mu syncs.Mutex // protects the following fields
|
||
|
||
shutdownOnce sync.Once // guards calling [nodeBackend.shutdown]
|
||
readyCh chan struct{} // closed by [nodeBackend.ready]; nil after shutdown
|
||
|
||
// NetMap is the most recently set full netmap from the controlclient.
|
||
// It can't be mutated in place once set. Because it can't be mutated in place,
|
||
// delta updates from the control server don't apply to it. Instead, use
|
||
// the peers map to get up-to-date information on the state of peers.
|
||
// In general, avoid using the netMap.Peers slice. We'd like it to go away
|
||
// as of 2023-09-17.
|
||
// TODO(nickkhyl): make it an atomic pointer to avoid the need for a mutex?
|
||
netMap *netmap.NetworkMap
|
||
|
||
// peers is the set of current peers and their current values after applying
|
||
// delta node mutations as they come in (with mu held). The map values can be
|
||
// given out to callers, but the map itself can be mutated in place (with mu held)
|
||
// and must not escape the [nodeBackend].
|
||
peers map[tailcfg.NodeID]tailcfg.NodeView
|
||
|
||
// nodeByAddr maps nodes' own addresses (excluding subnet routes) to node IDs.
|
||
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
|
||
nodeByAddr map[netip.Addr]tailcfg.NodeID
|
||
|
||
// nodeByKey is an index of node public key to node ID for fast lookups.
|
||
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
|
||
nodeByKey map[key.NodePublic]tailcfg.NodeID
|
||
|
||
// nodeByWGString indexes wireguard-go's truncated peer-string form
|
||
// (see [key.NodePublic.WireGuardGoString]) to node ID. It mirrors
|
||
// nodeByKey and lets the wireguard-go log path resolve
|
||
// "peer(XXXX…YYYY)" references in O(1), without scanning every
|
||
// peer, while still tolerating the fact that the wireguard-go form
|
||
// is lossy and can't be inverted to a [key.NodePublic].
|
||
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
|
||
nodeByWGString map[string]tailcfg.NodeID
|
||
|
||
// nodeByStableID is an index of peer stable node ID to node ID.
|
||
// Unlike nodeByAddr and nodeByKey, it covers only peers, not the
|
||
// self node.
|
||
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
|
||
nodeByStableID map[tailcfg.StableNodeID]tailcfg.NodeID
|
||
|
||
// nodeByName maps MagicDNS hostnames (lowercase, no trailing dot) to
|
||
// node IDs. Both the FQDN and the short name (suffix stripped) are
|
||
// keys. It is used by the tsdial MagicDNS resolution callback.
|
||
// It is mutated in place (with mu held) and must not escape the [nodeBackend].
|
||
nodeByName map[string]tailcfg.NodeID
|
||
|
||
// extraDNS stores DNS.ExtraRecords A/AAAA entries from the netmap
|
||
// (typically service VIPs pushed by control), keyed by canonicalized
|
||
// hostname (lowercase, no trailing dot).
|
||
extraDNS map[string]netip.Addr
|
||
|
||
// userProfiles is the live set of user profiles, updated incrementally
|
||
// by mergeUserProfiles as deltas arrive. It parallels the peers map:
|
||
// netMap.UserProfiles is the frozen snapshot from the last full install,
|
||
// while this field reflects incremental updates. Readers that need a
|
||
// snapshot (e.g. the legacy Notify.NetMap path) must clone this map.
|
||
userProfiles map[tailcfg.UserID]tailcfg.UserProfileView
|
||
|
||
// packetFilterRules and packetFilter are the live packet filter state,
|
||
// updated by setPacketFilter as deltas arrive. Like userProfiles, they
|
||
// exist separately from netMap's frozen fields so that concurrent
|
||
// JSON-encoding of a Notify.NetMap snapshot doesn't race with writes.
|
||
packetFilterRules views.Slice[tailcfg.FilterRule]
|
||
packetFilter []filter.Match
|
||
|
||
// keyWaitersForTest is the test-only registry of channels waiting for
|
||
// a given peer key to first appear in the netmap. See
|
||
// [nodeBackend.AwaitNodeKeyForTest]. It is populated lazily and remains
|
||
// nil in production, where no test installs a waiter.
|
||
keyWaitersForTest map[key.NodePublic]chan struct{}
|
||
|
||
// tsmpLearnedDisco records, per node key, a peer disco key that was
|
||
// learned via TSMP (that is, over an existing WireGuard session with
|
||
// that peer). When a netmap update later reports the same disco key
|
||
// change, the peer's WireGuard session does not need to be reset,
|
||
// because the change demonstrably arrived over a working session.
|
||
// See [nodeBackend.discoChangedLocked].
|
||
tsmpLearnedDisco map[key.NodePublic]key.DiscoPublic
|
||
|
||
// routeMgr tracks this node's view of which IPs route to which
|
||
// peers and publishes lock-free snapshots for the data plane and
|
||
// the OS router. It is initialized once and immutable, but its
|
||
// Begin/Commit mutations must be serialized, which we do by only
|
||
// mutating it with mu held.
|
||
routeMgr *routemanager.RouteManager
|
||
}
|
||
|
||
func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *nodeBackend {
|
||
ctx, ctxCancel := context.WithCancelCause(ctx)
|
||
nb := &nodeBackend{
|
||
logf: logf,
|
||
ctx: ctx,
|
||
ctxCancel: ctxCancel,
|
||
eventClient: bus.Client("ipnlocal.nodeBackend"),
|
||
readyCh: make(chan struct{}),
|
||
routeMgr: routemanager.New(logf),
|
||
}
|
||
// Default filter blocks everything and logs nothing.
|
||
noneFilter := filter.NewAllowNone(logger.Discard, &netipx.IPSet{})
|
||
nb.filterAtomic.Store(noneFilter)
|
||
nb.derpMapViewPub = eventbus.Publish[tailcfg.DERPMapView](nb.eventClient)
|
||
return nb
|
||
}
|
||
|
||
// Context returns a context that is canceled when the [nodeBackend] shuts down,
|
||
// either because [LocalBackend] is switching to a different [nodeBackend]
|
||
// or is shutting down itself.
|
||
func (nb *nodeBackend) Context() context.Context {
|
||
return nb.ctx
|
||
}
|
||
|
||
// Self returns the current node.
|
||
func (nb *nodeBackend) Self() tailcfg.NodeView {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return tailcfg.NodeView{}
|
||
}
|
||
return nb.netMap.SelfNode
|
||
}
|
||
|
||
func (nb *nodeBackend) SelfUserID() tailcfg.UserID {
|
||
self := nb.Self()
|
||
if !self.Valid() {
|
||
return 0
|
||
}
|
||
return self.User()
|
||
}
|
||
|
||
// SelfHasCap reports whether the specified capability was granted to the self node in the most recent netmap.
|
||
func (nb *nodeBackend) SelfHasCap(wantCap tailcfg.NodeCapability) bool {
|
||
return nb.SelfHasCapOr(wantCap, false)
|
||
}
|
||
|
||
// SelfHasCapOr is like [nodeBackend.SelfHasCap], but returns the specified default value
|
||
// if the netmap is not available yet.
|
||
func (nb *nodeBackend) SelfHasCapOr(wantCap tailcfg.NodeCapability, def bool) bool {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return def
|
||
}
|
||
return nb.netMap.AllCaps.Contains(wantCap)
|
||
}
|
||
|
||
func (nb *nodeBackend) NetworkProfile() ipn.NetworkProfile {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return ipn.NetworkProfile{
|
||
// These are ok to call with nil netMap.
|
||
MagicDNSName: nb.netMap.MagicDNSSuffix(),
|
||
DomainName: nb.netMap.DomainName(),
|
||
DisplayName: nb.netMap.TailnetDisplayName(),
|
||
}
|
||
}
|
||
|
||
// TODO(nickkhyl): update it to return a [tailcfg.DERPMapView]?
|
||
func (nb *nodeBackend) DERPMap() *tailcfg.DERPMap {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return nil
|
||
}
|
||
return nb.netMap.DERPMap
|
||
}
|
||
|
||
func (nb *nodeBackend) NodeByAddr(ip netip.Addr) (_ tailcfg.NodeID, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nid, ok := nb.nodeByAddr[ip]
|
||
return nid, ok
|
||
}
|
||
|
||
func (nb *nodeBackend) NodeByKey(k key.NodePublic) (_ tailcfg.NodeID, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nid, ok := nb.nodeByKey[k]
|
||
return nid, ok
|
||
}
|
||
|
||
// PeerAllowedIPs returns the prefixes from which the peer with the
|
||
// given public key is currently allowed to originate traffic, or
|
||
// ok=false if the key is unknown or the peer currently contributes no
|
||
// prefixes.
|
||
func (nb *nodeBackend) PeerAllowedIPs(k key.NodePublic) ([]netip.Prefix, bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
id, ok := nb.nodeByKey[k]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
// Holding nb.mu satisfies routeMgr's serialization requirement:
|
||
// all routeMgr mutations also run under nb.mu.
|
||
return nb.routeMgr.PeerAllowedIPs(id)
|
||
}
|
||
|
||
// NodeByWireGuardString returns the node ID of the peer whose
|
||
// [key.NodePublic.WireGuardGoString] form is s (e.g. "peer(IMTB…r7lM)").
|
||
// ok is false if no current peer matches.
|
||
func (nb *nodeBackend) NodeByWireGuardString(s string) (_ tailcfg.NodeID, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nid, ok := nb.nodeByWGString[s]
|
||
return nid, ok
|
||
}
|
||
|
||
func (nb *nodeBackend) NodeByID(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return nb.nodeByIDLocked(id)
|
||
}
|
||
|
||
// nodeByIDLocked returns the node (peer or self) with the given node
|
||
// ID. nb.mu must be held.
|
||
func (nb *nodeBackend) nodeByIDLocked(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) {
|
||
if nb.netMap != nil {
|
||
if self := nb.netMap.SelfNode; self.Valid() && self.ID() == id {
|
||
return self, true
|
||
}
|
||
}
|
||
n, ok := nb.peers[id]
|
||
return n, ok
|
||
}
|
||
|
||
func (nb *nodeBackend) PeerByStableID(id tailcfg.StableNodeID) (_ tailcfg.NodeView, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nid, ok := nb.nodeByStableID[id]; ok {
|
||
n, ok := nb.peers[nid]
|
||
return n, ok
|
||
}
|
||
return tailcfg.NodeView{}, false
|
||
}
|
||
|
||
func (nb *nodeBackend) UserByID(id tailcfg.UserID) (_ tailcfg.UserProfileView, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
u, ok := nb.userProfiles[id]
|
||
return u, ok
|
||
}
|
||
|
||
// Peers returns all the current peers in an undefined order.
|
||
func (nb *nodeBackend) Peers() []tailcfg.NodeView {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return slicesx.MapValues(nb.peers)
|
||
}
|
||
|
||
// PeerByID returns the current state of the peer (not self) node with
|
||
// the given ID, or ok=false if it is not a current peer.
|
||
func (nb *nodeBackend) PeerByID(id tailcfg.NodeID) (_ tailcfg.NodeView, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
n, ok := nb.peers[id]
|
||
return n, ok
|
||
}
|
||
|
||
// peerDiscoKeys returns the disco public keys of all current peers,
|
||
// keyed by their node public keys. Peers without a disco key are
|
||
// omitted.
|
||
func (nb *nodeBackend) peerDiscoKeys() map[key.NodePublic]key.DiscoPublic {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
m := make(map[key.NodePublic]key.DiscoPublic, len(nb.peers))
|
||
for _, p := range nb.peers {
|
||
if dk := p.DiscoKey(); !dk.IsZero() {
|
||
m[p.Key()] = dk
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func (nb *nodeBackend) PeersForTest() []tailcfg.NodeView {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
ret := slicesx.MapValues(nb.peers)
|
||
slices.SortFunc(ret, func(a, b tailcfg.NodeView) int {
|
||
return cmp.Compare(a.ID(), b.ID())
|
||
})
|
||
return ret
|
||
}
|
||
|
||
func (nb *nodeBackend) CollectServices() bool {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return nb.netMap != nil && nb.netMap.CollectServices
|
||
}
|
||
|
||
// AppendMatchingPeers returns base with all peers that match pred appended.
|
||
//
|
||
// It acquires nb.mu to snapshot the peers but releases it before
|
||
// calling pred.
|
||
func (nb *nodeBackend) AppendMatchingPeers(base []tailcfg.NodeView, pred func(tailcfg.NodeView) bool) []tailcfg.NodeView {
|
||
nb.mu.Lock()
|
||
peers := slicesx.MapValues(nb.peers)
|
||
nb.mu.Unlock()
|
||
|
||
// Sort by node ID for deterministic results; the map iteration
|
||
// above is randomly ordered.
|
||
slices.SortFunc(peers, func(a, b tailcfg.NodeView) int {
|
||
return cmp.Compare(a.ID(), b.ID())
|
||
})
|
||
|
||
ret := base
|
||
for _, peer := range peers {
|
||
if pred(peer) {
|
||
ret = append(ret, peer)
|
||
}
|
||
}
|
||
return ret
|
||
}
|
||
|
||
// PeerCaps returns the capabilities that remote src IP has to
|
||
// ths current node.
|
||
func (nb *nodeBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return nb.peerCapsLocked(src)
|
||
}
|
||
|
||
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
|
||
if nb.netMap == nil {
|
||
return nil
|
||
}
|
||
filt := nb.filterAtomic.Load()
|
||
if filt == nil {
|
||
return nil
|
||
}
|
||
addrs := nb.netMap.GetAddresses()
|
||
for i := range addrs.Len() {
|
||
a := addrs.At(i)
|
||
if !a.IsSingleIP() {
|
||
continue
|
||
}
|
||
dst := a.Addr()
|
||
if dst.BitLen() == src.BitLen() { // match on family
|
||
return filt.CapsWithValues(src, dst)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PeerCapsForIP returns the capabilities that remote src IP has when
|
||
// talking to the given destination IP on this node. The destination may
|
||
// be any IP the node handles: its own tailnet address, a VIP service
|
||
// address, or any future routable IP.
|
||
func (nb *nodeBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return nil
|
||
}
|
||
filt := nb.filterAtomic.Load()
|
||
if filt == nil {
|
||
return nil
|
||
}
|
||
return filt.CapsWithValues(src, dst)
|
||
}
|
||
|
||
// PeerCapsForService returns the capabilities that remote src IP has when
|
||
// talking to the named VIP service on this node. The service name is
|
||
// resolved to its VIP addresses via the node's service IP mappings, and
|
||
// the first address matching the src IP family is used for cap lookup.
|
||
func (nb *nodeBackend) PeerCapsForService(src netip.Addr, svcName tailcfg.ServiceName) tailcfg.PeerCapMap {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return nil
|
||
}
|
||
filt := nb.filterAtomic.Load()
|
||
if filt == nil {
|
||
return nil
|
||
}
|
||
addrs := nb.netMap.GetVIPServiceIPMap()[svcName]
|
||
for _, ip := range addrs {
|
||
if ip.BitLen() == src.BitLen() {
|
||
return filt.CapsWithValues(src, ip)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PeerHasCap reports whether the peer contains the given capability string,
|
||
// with any value(s).
|
||
func (nb *nodeBackend) PeerHasCap(peer tailcfg.NodeView, wantCap tailcfg.PeerCapability) bool {
|
||
if !peer.Valid() {
|
||
return false
|
||
}
|
||
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
for _, ap := range peer.Addresses().All() {
|
||
if nb.peerHasCapLocked(ap.Addr(), wantCap) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (nb *nodeBackend) peerHasCapLocked(addr netip.Addr, wantCap tailcfg.PeerCapability) bool {
|
||
return nb.peerCapsLocked(addr).HasCapability(wantCap)
|
||
}
|
||
|
||
func (nb *nodeBackend) PeerHasPeerAPI(p tailcfg.NodeView) bool {
|
||
return nb.PeerAPIBase(p) != ""
|
||
}
|
||
|
||
// PeerAPIBase returns the "http://ip:port" URL base to reach peer's PeerAPI,
|
||
// or the empty string if the peer is invalid or doesn't support PeerAPI.
|
||
func (nb *nodeBackend) PeerAPIBase(p tailcfg.NodeView) string {
|
||
nb.mu.Lock()
|
||
nm := nb.netMap
|
||
nb.mu.Unlock()
|
||
return peerAPIBase(nm, p)
|
||
}
|
||
|
||
// RouteCheckReport is an interface that reports whether a peer is reachable by the current node.
|
||
type RouteCheckReport interface {
|
||
// IsReachable reports whether a peer is reachable by the current node.
|
||
IsReachable(tailcfg.NodeID) peernode.Reachability
|
||
}
|
||
|
||
// PeerIsReachable reports whether the current node can reach p.
|
||
// This function may return a result based on stale reachability data,
|
||
// either from the control plane or because the latest routecheck report is old.
|
||
// If rp is nil, then this will report whether p is connected to the control plane
|
||
// according to [tailcfg.NodeView.Online].
|
||
//
|
||
// The latest routecheck report will be considered if the current node has both
|
||
// [tailcfg.NodeAttrClientSideReachability] and [tailcfg.NodeAttrClientSideReachabilityRouteCheck]
|
||
// in its CapMap.
|
||
func (nb *nodeBackend) PeerIsReachable(rp RouteCheckReport, p tailcfg.NodeView) bool {
|
||
nb.mu.Lock()
|
||
nm := nb.netMap
|
||
nb.mu.Unlock()
|
||
|
||
if nm == nil || !p.Valid() {
|
||
// If there is no netmap, then how did we get a NodeView?
|
||
// Assuming that p came from the control plane,
|
||
// report whether it was connected to tailcontrol.
|
||
return p.Valid() && p.Online().Get()
|
||
}
|
||
|
||
self := nm.SelfNode
|
||
useRouteCheck := isRouteCheckEnabled(self)
|
||
|
||
if !useRouteCheck && !self.HasCap(tailcfg.NodeAttrClientSideReachability) {
|
||
// Legacy behavior is to always trust the control plane, which
|
||
// isn’t always correct because the peer could be slow to check
|
||
// in so that control marks it as offline.
|
||
// See tailscale/corp#32686.
|
||
return p.Online().Get()
|
||
}
|
||
|
||
if self.Valid() && self.ID() == p.ID() {
|
||
// This node can always reach itself.
|
||
return true
|
||
}
|
||
|
||
if !useRouteCheck && !self.HasCap(tailcfg.NodeAttrClientSideReachabilityRouteCheck) {
|
||
// TODO(sfllaw): The following does not actually test for client-side
|
||
// reachability. This would require a mechanism that tracks whether the
|
||
// current node can actually reach this peer, either because they are
|
||
// already communicating or because they can ping each other.
|
||
//
|
||
// Instead, it makes the client ignore p.Online completely.
|
||
//
|
||
// See tailscale/corp#32686.
|
||
return true
|
||
}
|
||
|
||
if rp == nil {
|
||
// The routecheck report hasn’t been collected yet,
|
||
// so fall back and report whether it was connected to tailcontrol.
|
||
return p.Online().Get()
|
||
}
|
||
r := rp.IsReachable(p.ID())
|
||
if r == peernode.Unknown {
|
||
// Reachability is unknown, because the node is a new router, so fall back.
|
||
return p.Online().Get()
|
||
}
|
||
return r.IsReachable()
|
||
}
|
||
|
||
func nodeIP(n tailcfg.NodeView, pred func(netip.Addr) bool) netip.Addr {
|
||
for _, pfx := range n.Addresses().All() {
|
||
if pfx.IsSingleIP() && pred(pfx.Addr()) {
|
||
return pfx.Addr()
|
||
}
|
||
}
|
||
return netip.Addr{}
|
||
}
|
||
|
||
func (nb *nodeBackend) NetMap() *netmap.NetworkMap {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return nb.netMap
|
||
}
|
||
|
||
func (nb *nodeBackend) netMapWithPeers() *netmap.NetworkMap {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return nil
|
||
}
|
||
nm := new(*nb.netMap) // shallow clone
|
||
nm.Peers = slicesx.MapValues(nb.peers)
|
||
slices.SortFunc(nm.Peers, func(a, b tailcfg.NodeView) int {
|
||
return cmp.Compare(a.ID(), b.ID())
|
||
})
|
||
nm.UserProfiles = maps.Clone(nb.userProfiles)
|
||
nm.PacketFilterRules = nb.packetFilterRules
|
||
nm.PacketFilter = nb.packetFilter
|
||
return nm
|
||
}
|
||
|
||
func (nb *nodeBackend) SetNetMap(nm *netmap.NetworkMap) (discoChanged []key.NodePublic, routeChanged routemanager.PeersWithRouteChanges) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nb.netMap = nm
|
||
nb.updateNodeByAddrLocked()
|
||
nb.updateNodeByKeyLocked()
|
||
nb.updateNodeByStableIDLocked()
|
||
nb.updateNodeByNameLocked()
|
||
discoChanged, routeChanged = nb.updatePeersLocked()
|
||
nb.signalKeyWaitersForTestLocked()
|
||
if nm != nil {
|
||
nb.userProfiles = maps.Clone(nm.UserProfiles)
|
||
nb.packetFilterRules = nm.PacketFilterRules
|
||
nb.packetFilter = nm.PacketFilter
|
||
nb.derpMapViewPub.Publish(nm.DERPMap.View())
|
||
} else {
|
||
nb.userProfiles = nil
|
||
nb.packetFilterRules = views.Slice[tailcfg.FilterRule]{}
|
||
nb.packetFilter = nil
|
||
nb.derpMapViewPub.Publish(tailcfg.DERPMapView{})
|
||
}
|
||
return discoChanged, routeChanged
|
||
}
|
||
|
||
// AwaitNodeKeyForTest returns a channel that is closed once a peer with the
|
||
// given node key first appears in this nodeBackend's peer index, or
|
||
// immediately (a closed channel) if it's already present. It is intended for
|
||
// in-process benchmarks that drive synthetic netmap deltas and need a
|
||
// zero-overhead signal that the client has applied a delta, replacing
|
||
// poll-based [local.Client.WhoIsNodeKey] loops in tests. It panics outside
|
||
// of tests.
|
||
func (nb *nodeBackend) AwaitNodeKeyForTest(k key.NodePublic) <-chan struct{} {
|
||
testenv.AssertInTest()
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if _, ok := nb.nodeByKey[k]; ok {
|
||
return syncs.ClosedChan()
|
||
}
|
||
if ch, ok := nb.keyWaitersForTest[k]; ok {
|
||
return ch
|
||
}
|
||
ch := make(chan struct{})
|
||
mak.Set(&nb.keyWaitersForTest, k, ch)
|
||
return ch
|
||
}
|
||
|
||
// signalKeyWaitersForTestLocked closes any waiter channels whose keys now
|
||
// appear in nb.nodeByKey. It is cheap when there are no waiters, which is
|
||
// the common case in production. It is called from [nodeBackend.SetNetMap]
|
||
// after the per-key index has been rebuilt.
|
||
//
|
||
// Caller must hold nb.mu.
|
||
func (nb *nodeBackend) signalKeyWaitersForTestLocked() {
|
||
for k, ch := range nb.keyWaitersForTest {
|
||
if _, ok := nb.nodeByKey[k]; ok {
|
||
close(ch)
|
||
delete(nb.keyWaitersForTest, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (nb *nodeBackend) updateNodeByAddrLocked() {
|
||
nm := nb.netMap
|
||
if nm == nil {
|
||
nb.nodeByAddr = nil
|
||
return
|
||
}
|
||
|
||
addNodeAddr := func(n tailcfg.NodeView) {
|
||
for _, ipp := range n.Addresses().All() {
|
||
if ipp.IsSingleIP() {
|
||
nb.nodeByAddr[ipp.Addr()] = n.ID()
|
||
}
|
||
}
|
||
}
|
||
mapx.RepopulateNonzero(&nb.nodeByAddr, func() {
|
||
if nm.SelfNode.Valid() {
|
||
addNodeAddr(nm.SelfNode)
|
||
}
|
||
for _, p := range nm.Peers {
|
||
addNodeAddr(p)
|
||
}
|
||
})
|
||
}
|
||
|
||
func (nb *nodeBackend) updateNodeByKeyLocked() {
|
||
nm := nb.netMap
|
||
if nm == nil {
|
||
nb.nodeByKey = nil
|
||
nb.nodeByWGString = nil
|
||
return
|
||
}
|
||
|
||
mapx.RepopulateNonzero(&nb.nodeByKey, func() {
|
||
if nm.SelfNode.Valid() {
|
||
nb.nodeByKey[nm.SelfNode.Key()] = nm.SelfNode.ID()
|
||
}
|
||
for _, p := range nm.Peers {
|
||
nb.nodeByKey[p.Key()] = p.ID()
|
||
}
|
||
})
|
||
mapx.RepopulateNonzero(&nb.nodeByWGString, func() {
|
||
if nm.SelfNode.Valid() {
|
||
nb.nodeByWGString[nm.SelfNode.Key().WireGuardGoString()] = nm.SelfNode.ID()
|
||
}
|
||
for _, p := range nm.Peers {
|
||
nb.nodeByWGString[p.Key().WireGuardGoString()] = p.ID()
|
||
}
|
||
})
|
||
}
|
||
|
||
func (nb *nodeBackend) updateNodeByStableIDLocked() {
|
||
nm := nb.netMap
|
||
if nm == nil {
|
||
nb.nodeByStableID = nil
|
||
return
|
||
}
|
||
|
||
mapx.RepopulateNonzero(&nb.nodeByStableID, func() {
|
||
for _, p := range nm.Peers {
|
||
nb.nodeByStableID[p.StableID()] = p.ID()
|
||
}
|
||
})
|
||
}
|
||
|
||
// addNodeNameLocked adds both the FQDN and short-name keys for the given
|
||
// node to nb.nodeByName. nb.mu must be held.
|
||
func (nb *nodeBackend) addNodeNameLocked(name string, nid tailcfg.NodeID) {
|
||
if name == "" {
|
||
// We might support name-less nodes in the future; tailscale/corp#43949
|
||
return
|
||
}
|
||
canon := strings.ToLower(strings.TrimSuffix(name, "."))
|
||
mak.Set(&nb.nodeByName, canon, nid)
|
||
if suffix := nb.netMap.MagicDNSSuffix(); dnsname.HasSuffix(canon, suffix) {
|
||
mak.Set(&nb.nodeByName, dnsname.TrimSuffix(canon, suffix), nid)
|
||
}
|
||
}
|
||
|
||
// removeNodeNameLocked removes both the FQDN and short-name keys for the
|
||
// given node from nb.nodeByName. nb.mu must be held.
|
||
func (nb *nodeBackend) removeNodeNameLocked(name string) {
|
||
if name == "" {
|
||
// We might support name-less nodes in the future; tailscale/corp#43949
|
||
return
|
||
}
|
||
canon := strings.ToLower(strings.TrimSuffix(name, "."))
|
||
delete(nb.nodeByName, canon)
|
||
if suffix := nb.netMap.MagicDNSSuffix(); dnsname.HasSuffix(canon, suffix) {
|
||
delete(nb.nodeByName, dnsname.TrimSuffix(canon, suffix))
|
||
}
|
||
}
|
||
|
||
func (nb *nodeBackend) updateNodeByNameLocked() {
|
||
nm := nb.netMap
|
||
if nm == nil {
|
||
nb.nodeByName = nil
|
||
nb.extraDNS = nil
|
||
return
|
||
}
|
||
|
||
mapx.RepopulateNonzero(&nb.nodeByName, func() {
|
||
if nm.SelfNode.Valid() {
|
||
nb.addNodeNameLocked(nm.SelfNode.Name(), nm.SelfNode.ID())
|
||
}
|
||
for _, p := range nm.Peers {
|
||
nb.addNodeNameLocked(p.Name(), p.ID())
|
||
}
|
||
})
|
||
|
||
// Rebuild extraDNS from DNS.ExtraRecords (service VIPs, etc).
|
||
nb.extraDNS = nil
|
||
for _, rec := range nm.DNS.ExtraRecords {
|
||
if rec.Type != "" {
|
||
continue
|
||
}
|
||
ip, err := netip.ParseAddr(rec.Value)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
mak.Set(&nb.extraDNS, strings.ToLower(strings.TrimSuffix(rec.Name, ".")), ip)
|
||
}
|
||
}
|
||
|
||
// NodeByName returns the node ID for a MagicDNS hostname. The input
|
||
// must be lowercase with no trailing dot; both short names ("foo") and
|
||
// FQDNs ("foo.tail-scale.ts.net") are accepted.
|
||
func (nb *nodeBackend) NodeByName(hostname string) (_ tailcfg.NodeID, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nid, ok := nb.nodeByName[hostname]
|
||
return nid, ok
|
||
}
|
||
|
||
// ExtraDNSByName returns the IP for a DNS.ExtraRecords entry (e.g. service VIPs).
|
||
func (nb *nodeBackend) ExtraDNSByName(hostname string) (_ netip.Addr, ok bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
ip, ok := nb.extraDNS[hostname]
|
||
return ip, ok
|
||
}
|
||
|
||
func (nb *nodeBackend) updatePeersLocked() (discoChanged []key.NodePublic, routeChanged routemanager.PeersWithRouteChanges) {
|
||
nm := nb.netMap
|
||
oldIDs := slices.Collect(maps.Keys(nb.peers))
|
||
|
||
// Snapshot the previous disco keys (by node key) before the peers
|
||
// map is repopulated, to detect restarted peers below.
|
||
var prevDisco map[key.NodePublic]key.DiscoPublic
|
||
for _, p := range nb.peers {
|
||
if !p.DiscoKey().IsZero() {
|
||
mak.Set(&prevDisco, p.Key(), p.DiscoKey())
|
||
}
|
||
}
|
||
|
||
if nm == nil {
|
||
nb.peers = nil
|
||
} else {
|
||
mapx.RepopulateNonzero(&nb.peers, func() {
|
||
for _, p := range nm.Peers {
|
||
nb.peers[p.ID()] = p
|
||
}
|
||
})
|
||
}
|
||
|
||
for _, p := range nb.peers {
|
||
if prev, ok := prevDisco[p.Key()]; ok && nb.discoChangedLocked(p.Key(), prev, p.DiscoKey()) {
|
||
discoChanged = append(discoChanged, p.Key())
|
||
}
|
||
}
|
||
// Drop TSMP-learned disco keys for peers no longer in the netmap.
|
||
for k := range nb.tsmpLearnedDisco {
|
||
if _, ok := nb.nodeByKey[k]; !ok {
|
||
delete(nb.tsmpLearnedDisco, k)
|
||
}
|
||
}
|
||
|
||
// Resync the route manager to the new peer set. Upserts of
|
||
// unchanged peers are cheap no-ops; this is a full-netmap event,
|
||
// so O(n) work is expected here.
|
||
rt := nb.routeMgr.Begin()
|
||
for _, id := range oldIDs {
|
||
if _, ok := nb.peers[id]; !ok {
|
||
rt.RemovePeer(id)
|
||
}
|
||
}
|
||
for _, p := range nb.peers {
|
||
rt.UpsertPeer(p)
|
||
}
|
||
res := rt.Commit()
|
||
return discoChanged, res.AllowedIPs
|
||
}
|
||
|
||
// recordTSMPLearnedDisco notes that a peer's new disco key was learned via
|
||
// TSMP, so the netmap update carrying the same change need not reset the
|
||
// peer's WireGuard session. See the [nodeBackend.tsmpLearnedDisco] field doc.
|
||
func (nb *nodeBackend) recordTSMPLearnedDisco(pub key.NodePublic, disco key.DiscoPublic) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
mak.Set(&nb.tsmpLearnedDisco, pub, disco)
|
||
}
|
||
|
||
// discoChangedLocked reports whether a peer's disco key change from prev to
|
||
// cur should reset the peer's WireGuard session. A changed disco key means
|
||
// the peer restarted, so any existing session key material is dead weight;
|
||
// resetting lets the handshake start over immediately. The exception is a
|
||
// key change already learned via TSMP: that arrived over a working WireGuard
|
||
// session with the peer, so the session is demonstrably fine and is kept.
|
||
//
|
||
// It consumes any [nodeBackend.tsmpLearnedDisco] entry for pub.
|
||
// nb.mu must be held.
|
||
func (nb *nodeBackend) discoChangedLocked(pub key.NodePublic, prev, cur key.DiscoPublic) bool {
|
||
if prev.IsZero() || cur.IsZero() || prev == cur {
|
||
return false
|
||
}
|
||
if discoTSMP, ok := nb.tsmpLearnedDisco[pub]; ok {
|
||
delete(nb.tsmpLearnedDisco, pub)
|
||
if discoTSMP == cur {
|
||
nb.logf("nodeBackend: skipping WireGuard session reset (TSMP key): %s changed from %q to %q",
|
||
pub.ShortString(), prev, cur)
|
||
return false
|
||
}
|
||
// The new disco key does not match what we received via
|
||
// TSMP for this peer. This is unexpected, though possible
|
||
// if processing a change in a large netmap ends up taking
|
||
// longer than the 2 second timeout in
|
||
// [controlclient.mapRoutineState.UpdateNetmapDelta], or if
|
||
// the context is cancelled mid update. Log the event, and reset
|
||
// the session as it is possibly a stale entry in the map
|
||
// instead of a TSMP disco key update that led us here.
|
||
nb.logf("nodeBackend: [unexpected] using TSMP key for %s (control stale): tsmp=%q control=%q old=%q",
|
||
pub.ShortString(), discoTSMP, cur, prev)
|
||
metricTSMPLearnedKeyMismatch.Add(1)
|
||
}
|
||
nb.logf("nodeBackend: peer %s disco key changed from %q to %q", pub.ShortString(), prev, cur)
|
||
return true
|
||
}
|
||
|
||
// routePrefs is the subset of routing-relevant prefs (and derived
|
||
// state) that [nodeBackend.updateRouteManagerPrefs] pushes into the
|
||
// RouteManager.
|
||
type routePrefs struct {
|
||
// ExitNodeID is the stable node ID of the selected exit node, or
|
||
// zero if none is selected.
|
||
ExitNodeID tailcfg.StableNodeID
|
||
|
||
// ExitNodeSelected is whether the prefs select any exit node at
|
||
// all, even one that doesn't resolve (e.g. an ExitNodeIP that
|
||
// never resolved to an ExitNodeID); it mirrors
|
||
// routerConfigLocked's blackhole condition.
|
||
ExitNodeSelected bool
|
||
|
||
// RouteAll is whether advertised subnet routes from peers are
|
||
// accepted.
|
||
RouteAll bool
|
||
|
||
// OneCGNAT is whether the OS route set should collapse peers'
|
||
// CGNAT addresses into the single /10 route.
|
||
OneCGNAT bool
|
||
}
|
||
|
||
// updateRouteManagerPrefs pushes p into the route manager.
|
||
//
|
||
// It returns the peers whose allowed source prefixes changed as a
|
||
// result, for example the old and new exit node when the selection
|
||
// changes.
|
||
func (nb *nodeBackend) updateRouteManagerPrefs(p routePrefs) routemanager.PeersWithRouteChanges {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
var exitID tailcfg.NodeID
|
||
if !p.ExitNodeID.IsZero() {
|
||
// The lookup can miss: the selected exit node may not exist,
|
||
// or may be a placeholder like MDM's "auto:any" awaiting
|
||
// resolution. ExitNodeSelected then makes the RouteManager
|
||
// blackhole internet traffic rather than treat it as "no
|
||
// exit node"; see [routemanager.Prefs.ExitNodeSelected].
|
||
exitID = nb.nodeByStableID[p.ExitNodeID]
|
||
}
|
||
rt := nb.routeMgr.Begin()
|
||
rt.SetPrefs(routemanager.Prefs{
|
||
ExitNodeID: exitID,
|
||
ExitNodeSelected: p.ExitNodeSelected,
|
||
RouteAll: p.RouteAll,
|
||
})
|
||
rt.SetTailnetConfig(routemanager.TailnetConfig{OneCGNAT: p.OneCGNAT})
|
||
res := rt.Commit()
|
||
return res.AllowedIPs
|
||
}
|
||
|
||
// updateRouteManagerExtras pushes extra WireGuard-only allowed IPs
|
||
// into the route manager, obtained by calling fn (the
|
||
// [ipnext.Hooks.ExtraWireGuardAllowedIPs] hook) with a sequence of
|
||
// the current peers.
|
||
//
|
||
// It returns the peers whose allowed source prefixes changed as a
|
||
// result.
|
||
func (nb *nodeBackend) updateRouteManagerExtras(fn func(peers iter.Seq2[tailcfg.NodeID, key.NodePublic]) map[tailcfg.NodeID][]netip.Prefix) routemanager.PeersWithRouteChanges {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
extras := fn(func(yield func(tailcfg.NodeID, key.NodePublic) bool) {
|
||
for id, p := range nb.peers {
|
||
if !yield(id, p.Key()) {
|
||
return
|
||
}
|
||
}
|
||
})
|
||
rt := nb.routeMgr.Begin()
|
||
rt.SetExtraAllowedIPs(extras)
|
||
res := rt.Commit()
|
||
return res.AllowedIPs
|
||
}
|
||
|
||
// osRoutes returns the sorted set of prefixes that the route manager
|
||
// wants programmed into the OS routing table.
|
||
func (nb *nodeBackend) osRoutes() []netip.Prefix {
|
||
var routes []netip.Prefix
|
||
for pfx := range nb.routeMgr.OSRoutes().All() {
|
||
routes = append(routes, pfx)
|
||
}
|
||
tsaddr.SortPrefixes(routes)
|
||
return routes
|
||
}
|
||
|
||
// setPacketFilter stores the live packet filter rules and parsed
|
||
// matches. It does not touch the frozen netMap. nb.mu is acquired by
|
||
// this method.
|
||
func (nb *nodeBackend) setPacketFilter(rules views.Slice[tailcfg.FilterRule], parsed []filter.Match) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nb.packetFilterRules = rules
|
||
nb.packetFilter = parsed
|
||
}
|
||
|
||
// PacketFilter returns the current live packet filter matches.
|
||
func (nb *nodeBackend) PacketFilter() []filter.Match {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return nb.packetFilter
|
||
}
|
||
|
||
// mergeUserProfiles merges new/updated [tailcfg.UserProfileView]
|
||
// entries into the live userProfiles map. It does not touch
|
||
// netMap.UserProfiles (which is frozen once set). Callers must hold
|
||
// [LocalBackend.mu]. nb.mu is acquired by this method.
|
||
func (nb *nodeBackend) mergeUserProfiles(profiles map[tailcfg.UserID]tailcfg.UserProfileView) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
for id, up := range profiles {
|
||
mak.Set(&nb.userProfiles, id, up)
|
||
}
|
||
}
|
||
|
||
// netmapDeltaResult describes the side effects of applying netmap
|
||
// delta mutations that the caller must propagate.
|
||
type netmapDeltaResult struct {
|
||
// ChangedAllowedIPs are the peers whose allowed source prefixes
|
||
// changed; the caller syncs those peers to the WireGuard device.
|
||
ChangedAllowedIPs routemanager.PeersWithRouteChanges
|
||
|
||
// DiscoChanged is the set of peers whose disco key changed in a
|
||
// way that requires a WireGuard session reset (see
|
||
// [nodeBackend.discoChangedLocked]).
|
||
DiscoChanged set.Set[key.NodePublic]
|
||
}
|
||
|
||
// UpdateNetmapDelta applies the given netmap mutations to the live
|
||
// peer state. It returns the side effects for the caller to
|
||
// propagate, and reports whether it handled all of the mutations.
|
||
func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (res netmapDeltaResult, handled bool) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.netMap == nil {
|
||
return res, false
|
||
}
|
||
|
||
// Locally cloned mutable nodes, to avoid calling AsStruct (clone)
|
||
// multiple times on a node if it's mutated multiple times in this
|
||
// call (e.g. its endpoints + online status both change)
|
||
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
|
||
|
||
// Mirror peer add/remove mutations into the route manager.
|
||
// The Commit is unconditional (even if we panic or return false)
|
||
// to stay in sync with the peers map, which is likewise mutated
|
||
// in place as the loop runs. And if we return false, we'll just
|
||
// get a full netmap soon and reset all our state anyway.
|
||
rt := nb.routeMgr.Begin()
|
||
defer func() {
|
||
res.ChangedAllowedIPs = rt.Commit().AllowedIPs
|
||
}()
|
||
|
||
for _, m := range muts {
|
||
switch m := m.(type) {
|
||
case netmap.NodeMutationUpsert:
|
||
nid := m.Node.ID()
|
||
if old, ok := nb.peers[nid]; ok {
|
||
if old.Key() == m.Node.Key() {
|
||
if nb.discoChangedLocked(m.Node.Key(), old.DiscoKey(), m.Node.DiscoKey()) {
|
||
res.DiscoChanged.Make()
|
||
res.DiscoChanged.Add(m.Node.Key())
|
||
}
|
||
} else {
|
||
delete(nb.tsmpLearnedDisco, old.Key())
|
||
}
|
||
}
|
||
mak.Set(&nb.peers, nid, m.Node)
|
||
for _, ipp := range m.Node.Addresses().All() {
|
||
if ipp.IsSingleIP() {
|
||
mak.Set(&nb.nodeByAddr, ipp.Addr(), nid)
|
||
}
|
||
}
|
||
mak.Set(&nb.nodeByKey, m.Node.Key(), nid)
|
||
mak.Set(&nb.nodeByWGString, m.Node.Key().WireGuardGoString(), nid)
|
||
mak.Set(&nb.nodeByStableID, m.Node.StableID(), nid)
|
||
nb.addNodeNameLocked(m.Node.Name(), nid)
|
||
rt.UpsertPeer(m.Node)
|
||
continue
|
||
case netmap.NodeMutationRemove:
|
||
nid := m.NodeIDBeingMutated()
|
||
if old, ok := nb.peers[nid]; ok {
|
||
for _, ipp := range old.Addresses().All() {
|
||
if ipp.IsSingleIP() {
|
||
delete(nb.nodeByAddr, ipp.Addr())
|
||
}
|
||
}
|
||
delete(nb.nodeByKey, old.Key())
|
||
delete(nb.nodeByWGString, old.Key().WireGuardGoString())
|
||
delete(nb.nodeByStableID, old.StableID())
|
||
delete(nb.tsmpLearnedDisco, old.Key())
|
||
nb.removeNodeNameLocked(old.Name())
|
||
delete(nb.peers, nid)
|
||
rt.RemovePeer(nid)
|
||
}
|
||
continue
|
||
}
|
||
// Per-field mutation.
|
||
nid := m.NodeIDBeingMutated()
|
||
n, ok := mutableNodes[nid]
|
||
if !ok {
|
||
nv, ok := nb.peers[nid]
|
||
if !ok {
|
||
// TODO(bradfitz): unexpected metric?
|
||
return res, false
|
||
}
|
||
n = nv.AsStruct()
|
||
mak.Set(&mutableNodes, nv.ID(), n)
|
||
}
|
||
m.Apply(n)
|
||
}
|
||
for nid, n := range mutableNodes {
|
||
nb.peers[nid] = n.View()
|
||
}
|
||
nb.signalKeyWaitersForTestLocked()
|
||
return res, true
|
||
}
|
||
|
||
// unlockedNodesPermitted reports whether any peer with theUnsignedPeerAPIOnly bool set true has any of its allowed IPs
|
||
// in the specified packet filter.
|
||
//
|
||
// TODO(nickkhyl): It is here temporarily until we can move the whole [LocalBackend.updateFilterLocked] here,
|
||
// but change it so it builds and returns a filter for the current netmap/prefs instead of re-configuring the engine filter.
|
||
// Something like (*nodeBackend).RebuildFilters() (filter, jailedFilter *filter.Filter, changed bool) perhaps?
|
||
func (nb *nodeBackend) unlockedNodesPermitted(packetFilter []filter.Match) bool {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return packetFilterPermitsUnlockedNodes(nb.peers, packetFilter)
|
||
}
|
||
|
||
func (nb *nodeBackend) filter() *filter.Filter {
|
||
return nb.filterAtomic.Load()
|
||
}
|
||
|
||
func (nb *nodeBackend) setFilter(f *filter.Filter) {
|
||
nb.filterAtomic.Store(f)
|
||
}
|
||
|
||
func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, goos string) *dns.Config {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return dnsConfigForNetmap(nb.netMap, nb.peers, prefs, selfExpired, nb.logf, goos)
|
||
}
|
||
|
||
// magicDNSHostAddrs returns the MagicDNS A/AAAA answer for fqdn from
|
||
// the live node indexes, and whether the name is known. It backs
|
||
// [resolver.MagicDNSHosts.LookupHost], replacing the full
|
||
// dns.Config.Hosts map so that netmap deltas need no per-peer DNS
|
||
// rebuild.
|
||
func (nb *nodeBackend) magicDNSHostAddrs(fqdn dnsname.FQDN) (ips []netip.Addr, ok bool) {
|
||
if !buildfeatures.HasDNS {
|
||
return nil, false
|
||
}
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
n, ok := nb.nodeByFQDNLocked(fqdn)
|
||
if !ok || n.Addresses().Len() == 0 {
|
||
return nil, false
|
||
}
|
||
nm := nb.netMap
|
||
var flags magicDNSAddrsFlags
|
||
if nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs6) &&
|
||
!nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs4) {
|
||
flags |= selfV6Only
|
||
}
|
||
if nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA) {
|
||
flags |= wantAAAA
|
||
}
|
||
return magicDNSAddrs(n.Addresses(), flags), true
|
||
}
|
||
|
||
// magicDNSPTR returns the MagicDNS name of the node (peer or self)
|
||
// that owns ip, backing [resolver.MagicDNSHosts.LookupPTR].
|
||
func (nb *nodeBackend) magicDNSPTR(ip netip.Addr) (_ dnsname.FQDN, ok bool) {
|
||
if !buildfeatures.HasDNS {
|
||
return "", false
|
||
}
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nid, ok := nb.nodeByAddr[ip]
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
n, ok := nb.nodeByIDLocked(nid)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
fqdn, err := dnsname.ToFQDN(n.Name())
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return fqdn, true
|
||
}
|
||
|
||
// magicDNSSubdomainHost reports whether fqdn names a node with the
|
||
// [tailcfg.NodeAttrDNSSubdomainResolve] attribute, backing
|
||
// [resolver.MagicDNSHosts.SubdomainHost].
|
||
func (nb *nodeBackend) magicDNSSubdomainHost(fqdn dnsname.FQDN) bool {
|
||
if !buildfeatures.HasDNS {
|
||
return false
|
||
}
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
n, ok := nb.nodeByFQDNLocked(fqdn)
|
||
if !ok {
|
||
return false
|
||
}
|
||
if nm := nb.netMap; nm != nil && nm.SelfNode.Valid() && nm.SelfNode.ID() == n.ID() {
|
||
return nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve)
|
||
}
|
||
return n.CapMap().Contains(tailcfg.NodeAttrDNSSubdomainResolve)
|
||
}
|
||
|
||
// nodeByFQDNLocked returns the node (peer or self) with the given
|
||
// MagicDNS FQDN. nb.mu must be held.
|
||
func (nb *nodeBackend) nodeByFQDNLocked(fqdn dnsname.FQDN) (_ tailcfg.NodeView, ok bool) {
|
||
// The resolver already lowercases query names, but lowercase
|
||
// again (nearly free when already lowercase) so that no other
|
||
// caller of the [resolver.MagicDNSHosts] hook can miss on case.
|
||
nid, ok := nb.nodeByName[strings.ToLower(strings.TrimSuffix(string(fqdn), "."))]
|
||
if !ok {
|
||
return tailcfg.NodeView{}, false
|
||
}
|
||
return nb.nodeByIDLocked(nid)
|
||
}
|
||
|
||
// magicDNSAddrsFlags is a bitmask of flags for magicDNSAddrs.
|
||
type magicDNSAddrsFlags uint8
|
||
|
||
const (
|
||
selfV6Only magicDNSAddrsFlags = 1 << iota // the querying node has only IPv6 addresses
|
||
wantAAAA // include IPv6 addresses even for nodes that also have IPv4
|
||
)
|
||
|
||
// magicDNSAddrs returns the MagicDNS answer addresses for a node
|
||
// owning the given addresses: all of its IPv6 addresses if this node
|
||
// (the querier) is IPv6-only, otherwise everything except the node's
|
||
// IPv6 addresses when it also has IPv4, unless wantAAAA. The result
|
||
// may be empty (name exists, no records of the desired family).
|
||
func magicDNSAddrs(addrs views.Slice[netip.Prefix], flags magicDNSAddrsFlags) (ips []netip.Addr) {
|
||
wantAAAA := flags&wantAAAA != 0
|
||
selfV6Only := flags&selfV6Only != 0
|
||
var have4 bool
|
||
for _, addr := range addrs.All() {
|
||
if addr.Addr().Is4() {
|
||
have4 = true
|
||
break
|
||
}
|
||
}
|
||
for _, addr := range addrs.All() {
|
||
if selfV6Only {
|
||
if addr.Addr().Is6() {
|
||
ips = append(ips, addr.Addr())
|
||
}
|
||
continue
|
||
}
|
||
// If this node has an IPv4 address, then remove peers'
|
||
// IPv6 addresses for now, as we don't guarantee that
|
||
// the peer node actually can speak IPv6 correctly.
|
||
//
|
||
// https://github.com/tailscale/tailscale/issues/1152
|
||
// tracks adding the right capability reporting to
|
||
// enable AAAA in MagicDNS.
|
||
if addr.Addr().Is6() && have4 && !wantAAAA {
|
||
continue
|
||
}
|
||
ips = append(ips, addr.Addr())
|
||
}
|
||
return ips
|
||
}
|
||
|
||
func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
|
||
if !buildfeatures.HasUseExitNode {
|
||
return "", false
|
||
}
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
return exitNodeCanProxyDNS(nb.netMap, nb.peers, exitNodeID)
|
||
}
|
||
|
||
// ready signals that [LocalBackend] has completed the switch to this [nodeBackend]
|
||
// and any pending calls to [nodeBackend.Wait] must be unblocked.
|
||
func (nb *nodeBackend) ready() {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
if nb.readyCh != nil {
|
||
close(nb.readyCh)
|
||
}
|
||
}
|
||
|
||
// Wait blocks until [LocalBackend] completes the switch to this [nodeBackend]
|
||
// and calls [nodeBackend.ready]. It returns an error if the provided context
|
||
// is canceled or if the [nodeBackend] shuts down or is already shut down.
|
||
//
|
||
// It must not be called with the [LocalBackend]'s internal mutex held as [LocalBackend]
|
||
// may need to acquire it to complete the switch.
|
||
//
|
||
// TODO(nickkhyl): Relax this restriction once [LocalBackend]'s state machine
|
||
// runs in its own goroutine, or if we decide that waiting for the state machine
|
||
// restart to finish isn't necessary for [LocalBackend] to consider the switch complete.
|
||
// We mostly need this because of [LocalBackend.Start] acquiring b.mu and the fact that
|
||
// methods like [LocalBackend.SwitchProfile] must report any errors returned by it.
|
||
// Perhaps we could report those errors asynchronously as [health.Warnable]s?
|
||
func (nb *nodeBackend) Wait(ctx context.Context) error {
|
||
nb.mu.Lock()
|
||
readyCh := nb.readyCh
|
||
nb.mu.Unlock()
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-nb.ctx.Done():
|
||
return context.Cause(nb.ctx)
|
||
case <-readyCh:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// shutdown shuts down the [nodeBackend] and cancels its context
|
||
// with the provided cause.
|
||
func (nb *nodeBackend) shutdown(cause error) {
|
||
nb.shutdownOnce.Do(func() {
|
||
nb.doShutdown(cause)
|
||
})
|
||
}
|
||
|
||
func (nb *nodeBackend) doShutdown(cause error) {
|
||
nb.mu.Lock()
|
||
defer nb.mu.Unlock()
|
||
nb.ctxCancel(cause)
|
||
nb.readyCh = nil
|
||
nb.eventClient.Close()
|
||
}
|
||
|
||
// useWithExitNodeResolvers filters out resolvers so the ones that remain
|
||
// are all the ones marked for use with exit nodes.
|
||
func useWithExitNodeResolvers(resolvers []*dnstype.Resolver) []*dnstype.Resolver {
|
||
var filtered []*dnstype.Resolver
|
||
for _, res := range resolvers {
|
||
if res.UseWithExitNode {
|
||
filtered = append(filtered, res)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
// useWithExitNodeRoutes filters out routes so the ones that remain
|
||
// are either zero-length resolver lists, or lists containing only
|
||
// resolvers marked for use with exit nodes.
|
||
func useWithExitNodeRoutes(routes map[string][]*dnstype.Resolver) map[string][]*dnstype.Resolver {
|
||
var filtered map[string][]*dnstype.Resolver
|
||
for suffix, resolvers := range routes {
|
||
// Suffixes with no resolvers represent a valid configuration,
|
||
// and should persist regardless of exit node considerations.
|
||
if len(resolvers) == 0 {
|
||
mak.Set(&filtered, suffix, make([]*dnstype.Resolver, 0))
|
||
continue
|
||
}
|
||
|
||
// In exit node contexts, we filter out resolvers not configured for use with
|
||
// exit nodes. If there are no such configured resolvers, there should not be an entry for that suffix.
|
||
filteredResolvers := useWithExitNodeResolvers(resolvers)
|
||
if len(filteredResolvers) > 0 {
|
||
mak.Set(&filtered, suffix, filteredResolvers)
|
||
}
|
||
}
|
||
|
||
return filtered
|
||
}
|
||
|
||
// dnsConfigForNetmap returns a *dns.Config for the given netmap,
|
||
// prefs, client OS version, and cloud hosting environment.
|
||
//
|
||
// The goos is runtime.GOOS usually, except in tests, where it may
|
||
// vary to test any OS's behavior from any host.
|
||
func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, goos string) *dns.Config {
|
||
if nm == nil {
|
||
return nil
|
||
}
|
||
if !buildfeatures.HasDNS {
|
||
return &dns.Config{}
|
||
}
|
||
|
||
// If the current node's key is expired, then we don't program any DNS
|
||
// configuration into the operating system. This ensures that if the
|
||
// DNS configuration specifies a DNS server that is only reachable over
|
||
// Tailscale, we don't break connectivity for the user.
|
||
//
|
||
// TODO(andrew-d): this also stops returning anything from quad-100; we
|
||
// could do the same thing as having "CorpDNS: false" and keep that but
|
||
// not program the OS?
|
||
if selfExpired {
|
||
return &dns.Config{}
|
||
}
|
||
|
||
dcfg := &dns.Config{
|
||
AcceptDNS: prefs.CorpDNS(),
|
||
Routes: map[dnsname.FQDN][]*dnstype.Resolver{},
|
||
Hosts: map[dnsname.FQDN][]netip.Addr{},
|
||
}
|
||
|
||
var addrFlags magicDNSAddrsFlags
|
||
if nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs6) &&
|
||
!nm.GetAddresses().ContainsFunc(tsaddr.PrefixIs4) {
|
||
// We only have IPv6 addresses ourselves.
|
||
addrFlags |= selfV6Only
|
||
dcfg.OnlyIPv6 = true
|
||
}
|
||
if nm.AllCaps.Contains(tailcfg.NodeAttrMagicDNSPeerAAAA) {
|
||
addrFlags |= wantAAAA
|
||
}
|
||
|
||
// Populate MagicDNS records. The internal quad-100 resolver pulls
|
||
// per-node records on demand from the live node indexes (see
|
||
// [nodeBackend.magicDNSHostAddrs]), so quad-100 can always respond
|
||
// to MagicDNS queries without dcfg.Hosts; details in
|
||
// https://github.com/tailscale/tailscale/issues/1886. dcfg.Hosts
|
||
// carries only what must be enumerable in full: control's
|
||
// DNS.ExtraRecords below, plus every node's records on Windows,
|
||
// whose hosts-file fallback path (see compileHostEntries in
|
||
// net/dns) needs the complete set.
|
||
if goos == "windows" {
|
||
set := func(name string, addrs views.Slice[netip.Prefix]) {
|
||
if addrs.Len() == 0 || name == "" {
|
||
return
|
||
}
|
||
fqdn, err := dnsname.ToFQDN(name)
|
||
if err != nil {
|
||
return // TODO: propagate error?
|
||
}
|
||
dcfg.Hosts[fqdn] = magicDNSAddrs(addrs, addrFlags)
|
||
}
|
||
set(nm.SelfName(), nm.GetAddresses())
|
||
for _, peer := range peers {
|
||
set(peer.Name(), peer.Addresses())
|
||
}
|
||
}
|
||
for _, rec := range nm.DNS.ExtraRecords {
|
||
switch rec.Type {
|
||
case "", "A", "AAAA":
|
||
// Treat these all the same for now: infer from the value
|
||
default:
|
||
// TODO: more
|
||
continue
|
||
}
|
||
ip, err := netip.ParseAddr(rec.Value)
|
||
if err != nil {
|
||
// Ignore.
|
||
continue
|
||
}
|
||
fqdn, err := dnsname.ToFQDN(rec.Name)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
dcfg.Hosts[fqdn] = append(dcfg.Hosts[fqdn], ip)
|
||
}
|
||
|
||
if !prefs.CorpDNS() {
|
||
return dcfg
|
||
}
|
||
|
||
for _, dom := range nm.DNS.Domains {
|
||
fqdn, err := dnsname.ToFQDN(dom)
|
||
if err != nil {
|
||
logf("[unexpected] non-FQDN search domain %q", dom)
|
||
}
|
||
dcfg.SearchDomains = append(dcfg.SearchDomains, fqdn)
|
||
}
|
||
if nm.DNS.Proxied { // actually means "enable MagicDNS"
|
||
for _, dom := range magicDNSRootDomains(nm) {
|
||
dcfg.Routes[dom] = nil // resolve internally with dcfg.Hosts
|
||
}
|
||
} else {
|
||
// Without MagicDNS domain routing, no Routes entry covers the
|
||
// node records served on demand via [magicDNSHosts]. Tell
|
||
// dns.Manager they exist, so it keeps quad-100 in the OS
|
||
// resolver path and the names still resolve, as they did when
|
||
// they were listed in dcfg.Hosts. Any netmap with a self node
|
||
// has such records.
|
||
dcfg.MagicDNSHostsUnrouted = nm.SelfNode.Valid()
|
||
}
|
||
|
||
addDefault := func(resolvers []*dnstype.Resolver) {
|
||
dcfg.DefaultResolvers = append(dcfg.DefaultResolvers, resolvers...)
|
||
}
|
||
|
||
addSplitDNSRoutes := func(routes map[string][]*dnstype.Resolver) {
|
||
for suffix, resolvers := range routes {
|
||
fqdn, err := dnsname.ToFQDN(suffix)
|
||
if err != nil {
|
||
logf("[unexpected] non-FQDN route suffix %q", suffix)
|
||
}
|
||
|
||
// Create map entry even if len(resolvers) == 0; Issue 2706.
|
||
// This lets the control plane send ExtraRecords for which we
|
||
// can authoritatively answer "name not exists" for when the
|
||
// control plane also sends this explicit but empty route
|
||
// making it as something we handle.
|
||
dcfg.Routes[fqdn] = slices.Clone(resolvers)
|
||
}
|
||
}
|
||
|
||
// If we're using an exit node and that exit node is new enough (1.19.x+)
|
||
// to run a DoH DNS proxy, then send all our DNS traffic through it,
|
||
// unless we find resolvers with UseWithExitNode set, in which case we use that.
|
||
if buildfeatures.HasUseExitNode {
|
||
if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok {
|
||
filtered := useWithExitNodeResolvers(nm.DNS.Resolvers)
|
||
if len(filtered) > 0 {
|
||
addDefault(filtered)
|
||
} else {
|
||
// If no default global resolvers with the override
|
||
// are configured, configure the exit node's resolver.
|
||
addDefault([]*dnstype.Resolver{{Addr: dohURL}})
|
||
}
|
||
|
||
addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes))
|
||
return dcfg
|
||
}
|
||
}
|
||
|
||
// If the user has set default resolvers ("override local DNS"), prefer to
|
||
// use those resolvers as the default, otherwise if there are WireGuard exit
|
||
// node resolvers, use those as the default.
|
||
if len(nm.DNS.Resolvers) > 0 {
|
||
addDefault(nm.DNS.Resolvers)
|
||
} else if buildfeatures.HasUseExitNode {
|
||
if resolvers, ok := wireguardExitNodeDNSResolvers(nm, peers, prefs.ExitNodeID()); ok {
|
||
addDefault(resolvers)
|
||
}
|
||
}
|
||
|
||
// Add split DNS routes, with no regard to exit node configuration.
|
||
addSplitDNSRoutes(nm.DNS.Routes)
|
||
|
||
if (envknob.UseWIPCode() || testenv.InTest()) && buildfeatures.HasConn25 && !prefs.AppConnector().Advertise {
|
||
// Add split DNS routes for conn25
|
||
if appRoutes := appc.AppDNSRoutes(nm.HasCap, nm.SelfNode); appRoutes != nil {
|
||
addSplitDNSRoutes(appRoutes)
|
||
}
|
||
}
|
||
|
||
// Set FallbackResolvers as the default resolvers in the
|
||
// scenarios that can't handle a purely split-DNS config. See
|
||
// https://github.com/tailscale/tailscale/issues/1743 for
|
||
// details.
|
||
switch {
|
||
case len(dcfg.DefaultResolvers) != 0:
|
||
// Default resolvers already set.
|
||
case !prefs.ExitNodeID().IsZero():
|
||
// When using an exit node, we send all DNS traffic to the exit node, so
|
||
// we don't need a fallback resolver.
|
||
//
|
||
// However, if the exit node is too old to run a DoH DNS proxy, then we
|
||
// need to use a fallback resolver as it's very likely the LAN resolvers
|
||
// will become unreachable.
|
||
//
|
||
// This is especially important on Apple OSes, where
|
||
// adding the default route to the tunnel interface makes
|
||
// it "primary", and we MUST provide VPN-sourced DNS
|
||
// settings or we break all DNS resolution.
|
||
//
|
||
// https://github.com/tailscale/tailscale/issues/1713
|
||
addDefault(nm.DNS.FallbackResolvers)
|
||
case len(dcfg.Routes) == 0:
|
||
// No settings requiring split DNS, no problem.
|
||
}
|
||
|
||
return dcfg
|
||
}
|