diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 7fd4c4b21..87dfe9815 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -883,6 +883,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/net/tsdial from tailscale.com/control/controlclient+ 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+ tailscale.com/net/tstun from tailscale.com/tsd+ + tailscale.com/net/udprelay from tailscale.com/ipn/ipnlocal tailscale.com/omit from tailscale.com/ipn/conffile tailscale.com/paths from tailscale.com/client/local+ 💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 394056295..64c8a4892 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -333,6 +333,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+ 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+ tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+ + tailscale.com/net/udprelay from tailscale.com/ipn/ipnlocal tailscale.com/omit from tailscale.com/ipn/conffile tailscale.com/paths from tailscale.com/client/local+ 💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 7d69b884d..59ff81bf8 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -76,6 +76,7 @@ import ( "tailscale.com/net/packet" "tailscale.com/net/tsaddr" "tailscale.com/net/tsdial" + "tailscale.com/net/udprelay" "tailscale.com/paths" "tailscale.com/portlist" "tailscale.com/posture" @@ -378,6 +379,7 @@ type LocalBackend struct { // c2nUpdateStatus is the status of c2n-triggered client update. c2nUpdateStatus updateStatus currentUser ipnauth.Actor + relayServer relayServer // or nil, initialized lazily // backgroundProfileResolvers are optional background profile resolvers. backgroundProfileResolvers set.HandleSet[profileResolver] @@ -1135,6 +1137,10 @@ func (b *LocalBackend) Shutdown() { b.sshServer.Shutdown() b.sshServer = nil } + if b.relayServer != nil { + b.relayServer.Close() + b.relayServer = nil + } b.closePeerAPIListenersLocked() if b.debugSink != nil { b.e.InstallCaptureHook(nil) @@ -4609,6 +4615,17 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce) b.sshServer = nil } } + + if oldp.RelayServerPort().Valid() && (newp.RelayServerPort == nil || + oldp.RelayServerPort().Get() != *newp.RelayServerPort) { + if b.relayServer != nil { + b.goTracker.Go(func() { + b.relayServer.Close() + }) + b.relayServer = nil + } + } + if netMap != nil { newProfile := profileFromView(netMap.UserProfiles[netMap.User()]) if newLoginName := newProfile.LoginName; newLoginName != "" { @@ -6060,6 +6077,40 @@ func (b *LocalBackend) resetAuthURLLocked() { b.authActor = nil } +// relayServer is the interface of the conditionally linked net/udprelay.Server. +type relayServer interface { + // AllocateEndpoint allocates a udprelay.ServerEndpoint for the provided + // pair of key.DiscoPublic's. It returns an error (udprelay.ErrServerClosed) + // if the server has been closed. + AllocateEndpoint(discoA, discoB key.DiscoPublic) (udprelay.ServerEndpoint, error) + + Close() error +} + +type newRelayServerFunc func(port int, addrs []netip.Addr) (relayServer, int, error) + +var newRelayServer newRelayServerFunc // or nil + +func registerNewRelayServer(fn newRelayServerFunc) { + newRelayServer = fn +} + +func (b *LocalBackend) relayServerOrInit() (_ relayServer, err error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.relayServer != nil { + return b.relayServer, nil + } + if newRelayServer == nil { + return nil, errors.New("no relay server support") + } + b.relayServer, _, err = newRelayServer(b.Prefs().RelayServerPort().Get(), []netip.Addr{netip.MustParseAddr("127.0.0.1")}) + if err != nil { + return nil, fmt.Errorf("newRelayServer: %w", err) + } + return b.relayServer, nil +} + func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && envknob.CanSSHD() } // ShouldRunWebClient reports whether the web client is being run diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index 21b808fd5..71c412a93 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -38,6 +38,7 @@ import ( "tailscale.com/net/sockstats" "tailscale.com/tailcfg" "tailscale.com/taildrop" + "tailscale.com/types/key" "tailscale.com/types/views" "tailscale.com/util/clientmetric" "tailscale.com/util/httphdr" @@ -388,6 +389,9 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { metricIngressCalls.Add(1) h.handleServeIngress(w, r) return + case "/v0/relay/endpoint": + h.handleServeRelayAllocateEndpoint(w, r) + return } if ph, ok := peerAPIHandlers[r.URL.Path]; ok { ph(h, w, r) @@ -1194,6 +1198,55 @@ func parseDriveFileExtensionForLog(path string) string { return fileExt } +func (h *peerAPIHandler) handleServeRelayAllocateEndpoint(w http.ResponseWriter, r *http.Request) { + logAndError := func(code int, publicMsg string) { + h.logf("relay: error (status=%d) handling request from %v: %s", code, h.remoteAddr, publicMsg) + http.Error(w, publicMsg, code) + } + if !h.ps.b.ShouldRunRelayServer() { + logAndError(http.StatusNotFound, "relay not enabled") + return + } + + if !h.PeerCaps().HasCapability(tailcfg.PeerCapabilityRelay) { + logAndError(http.StatusForbidden, "relay not permitted") + return + } + + if r.Method != httpm.POST { + logAndError(http.StatusMethodNotAllowed, "only POST method is allowed") + return + } + + var allocateEndpointReq struct { + DiscoKeys []key.DiscoPublic + } + err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&allocateEndpointReq) + if err != nil { + logAndError(http.StatusBadRequest, err.Error()) + return + } + if len(allocateEndpointReq.DiscoKeys) != 2 { + logAndError(http.StatusBadRequest, "2 disco public keys must be supplied") + return + } + + rs, err := h.ps.b.relayServerOrInit() + if err != nil { + logAndError(http.StatusInternalServerError, "error") + return + } + ep, err := rs.AllocateEndpoint(allocateEndpointReq.DiscoKeys[0], allocateEndpointReq.DiscoKeys[1]) + if err != nil { + logAndError(http.StatusInternalServerError, err.Error()) + return + } + err = json.NewEncoder(w).Encode(&ep) + if err != nil { + logAndError(http.StatusInternalServerError, err.Error()) + } +} + // newFakePeerAPIListener creates a new net.Listener that acts like // it's listening on the provided IP address and on TCP port 1. // diff --git a/ipn/ipnlocal/relay_default.go b/ipn/ipnlocal/relay_default.go new file mode 100644 index 000000000..b7398bee7 --- /dev/null +++ b/ipn/ipnlocal/relay_default.go @@ -0,0 +1,39 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ios + +package ipnlocal + +import ( + "net/netip" + + "tailscale.com/envknob" + "tailscale.com/net/udprelay" + "tailscale.com/tailcfg" +) + +func init() { + // Initialize the relay server constructor on all platforms except iOS (see + // build tag at top of file) for now as to limit the impact to binary size + // and resulting effect of pushing up against NetworkExtension limits. + // Eventually we will want to support the relay server on iOS, specifically + // on the Apple TV. Apple TVs are well-fitted to act as underlay relay + // servers as they are effectively always-on servers. + registerNewRelayServer(func(port int, addrs []netip.Addr) (relayServer, int, error) { + return udprelay.NewServer(port, addrs) + }) +} + +// ShouldRunRelayServer returns true if a relay server port has been set in prefs, +// TAILSCALE_USE_WIP_CODE environment variable is set, and the node has the +// tailcfg.NodeAttrRelayServer tailcfg.NodeCapability. +// +// TODO(jwhited): remove the envknob guard once APIs (peerapi endpoint, +// new disco message types) are stable. +func (b *LocalBackend) ShouldRunRelayServer() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.Prefs().RelayServerPort().Valid() && envknob.UseWIPCode() && + b.netMap != nil && b.netMap.SelfNode.HasCap(tailcfg.NodeAttrRelayServer) +} diff --git a/ipn/ipnlocal/relay_ios.go b/ipn/ipnlocal/relay_ios.go new file mode 100644 index 000000000..696f3abab --- /dev/null +++ b/ipn/ipnlocal/relay_ios.go @@ -0,0 +1,10 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ios + +package ipnlocal + +func (b *LocalBackend) ShouldRunRelayServer() bool { + return false +} diff --git a/net/udprelay/server.go b/net/udprelay/server_default.go similarity index 99% rename from net/udprelay/server.go rename to net/udprelay/server_default.go index 30fc08326..8a2ddeda0 100644 --- a/net/udprelay/server.go +++ b/net/udprelay/server_default.go @@ -1,6 +1,8 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +//go:build !ios + // Package udprelay contains constructs for relaying Disco and WireGuard packets // between Tailscale clients over UDP. This package is currently considered // experimental. diff --git a/net/udprelay/server_test.go b/net/udprelay/server_default_test.go similarity index 99% rename from net/udprelay/server_test.go rename to net/udprelay/server_default_test.go index 733e50b77..0d99dc8c2 100644 --- a/net/udprelay/server_test.go +++ b/net/udprelay/server_default_test.go @@ -1,6 +1,8 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +//go:build !ios + package udprelay import ( diff --git a/net/udprelay/server_ios.go b/net/udprelay/server_ios.go new file mode 100644 index 000000000..07c68571e --- /dev/null +++ b/net/udprelay/server_ios.go @@ -0,0 +1,8 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ios + +package udprelay + +type ServerEndpoint struct{}