mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-23 11:27:29 +00:00
feature/taildrop: start moving Taildrop out of LocalBackend
This adds a feature/taildrop package, a ts_omit_taildrop build tag, and starts moving code to feature/taildrop. In some cases, code remains where it was but is now behind a build tag. Future changes will move code to an extension and out of LocalBackend, etc. Updates #12614 Change-Id: Idf96c61144d1a5f707039ceb2ff59c99f5c1642f Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:

committed by
Brad Fitzpatrick

parent
dda2c0d2c2
commit
0c78f081a4
@@ -67,6 +67,7 @@ import (
|
||||
// and to further reduce the risk of accessing unexported methods or fields of [LocalBackend], the host interacts
|
||||
// with it via the [Backend] interface.
|
||||
type ExtensionHost struct {
|
||||
b Backend
|
||||
logf logger.Logf // prefixed with "ipnext:"
|
||||
|
||||
// allExtensions holds the extensions in the order they were registered,
|
||||
@@ -139,6 +140,7 @@ type Backend interface {
|
||||
// Overriding extensions is primarily used for testing.
|
||||
func NewExtensionHost(logf logger.Logf, sys *tsd.System, b Backend, overrideExts ...*ipnext.Definition) (_ *ExtensionHost, err error) {
|
||||
host := &ExtensionHost{
|
||||
b: b,
|
||||
logf: logger.WithPrefix(logf, "ipnext: "),
|
||||
workQueue: &execqueue.ExecQueue{},
|
||||
// The host starts with an empty profile and default prefs.
|
||||
@@ -332,6 +334,14 @@ func (h *ExtensionHost) SwitchToBestProfileAsync(reason string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Backend returns the [Backend] used by the extension host.
|
||||
func (h *ExtensionHost) Backend() Backend {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.b
|
||||
}
|
||||
|
||||
// RegisterProfileStateChangeCallback implements [ipnext.ProfileServices].
|
||||
func (h *ExtensionHost) RegisterProfileStateChangeCallback(cb ipnext.ProfileStateChangeCallback) (unregister func()) {
|
||||
if h == nil {
|
||||
|
@@ -30,7 +30,6 @@ import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -81,7 +80,6 @@ import (
|
||||
"tailscale.com/posture"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tstime"
|
||||
@@ -590,6 +588,8 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) Clock() tstime.Clock { return b.clock }
|
||||
|
||||
// FindExtensionByName returns an active extension with the given name,
|
||||
// or nil if no such extension exists.
|
||||
func (b *LocalBackend) FindExtensionByName(name string) any {
|
||||
@@ -1075,9 +1075,6 @@ func (b *LocalBackend) Shutdown() {
|
||||
defer cancel()
|
||||
b.sockstatLogger.Shutdown(ctx)
|
||||
}
|
||||
if b.peerAPIServer != nil {
|
||||
b.peerAPIServer.taildrop.Shutdown()
|
||||
}
|
||||
b.stopOfflineAutoUpdate()
|
||||
|
||||
b.unregisterNetMon()
|
||||
@@ -1291,7 +1288,9 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) {
|
||||
SSH_HostKeys: p.Hostinfo().SSH_HostKeys().AsSlice(),
|
||||
Location: p.Hostinfo().Location().AsStruct(),
|
||||
Capabilities: p.Capabilities().AsSlice(),
|
||||
TaildropTarget: b.taildropTargetStatus(p),
|
||||
}
|
||||
if f := hookSetPeerStatusTaildropTargetLocked; f != nil {
|
||||
f(b, ps, p)
|
||||
}
|
||||
if cm := p.CapMap(); cm.Len() > 0 {
|
||||
ps.CapMap = make(tailcfg.NodeCapMap, cm.Len())
|
||||
@@ -3248,6 +3247,17 @@ func (b *LocalBackend) sendTo(n ipn.Notify, recipient notificationTarget) {
|
||||
b.sendToLocked(n, recipient)
|
||||
}
|
||||
|
||||
var (
|
||||
// hookSetNotifyFilesWaitingLocked, if non-nil, is called in sendToLocked to
|
||||
// populate ipn.Notify.FilesWaiting when taildrop is linked in to the binary
|
||||
// and enabled on a LocalBackend.
|
||||
hookSetNotifyFilesWaitingLocked func(*LocalBackend, *ipn.Notify)
|
||||
|
||||
// hookSetPeerStatusTaildropTargetLocked, if non-nil, is called to populate PeerStatus
|
||||
// if taildrop is linked in to the binary and enabled on the LocalBackend.
|
||||
hookSetPeerStatusTaildropTargetLocked func(*LocalBackend, *ipnstate.PeerStatus, tailcfg.NodeView)
|
||||
)
|
||||
|
||||
// sendToLocked is like [LocalBackend.sendTo], but assumes b.mu is already held.
|
||||
func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget) {
|
||||
if n.Prefs != nil {
|
||||
@@ -3257,9 +3267,8 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget)
|
||||
n.Version = version.Long()
|
||||
}
|
||||
|
||||
apiSrv := b.peerAPIServer
|
||||
if mayDeref(apiSrv).taildrop.HasFilesWaiting() {
|
||||
n.FilesWaiting = &empty.Message{}
|
||||
if f := hookSetNotifyFilesWaitingLocked; f != nil {
|
||||
f(b, &n)
|
||||
}
|
||||
|
||||
for _, sess := range b.notifyWatchers {
|
||||
@@ -3273,32 +3282,6 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) sendFileNotify() {
|
||||
var n ipn.Notify
|
||||
|
||||
b.mu.Lock()
|
||||
for _, wakeWaiter := range b.fileWaiters {
|
||||
wakeWaiter()
|
||||
}
|
||||
apiSrv := b.peerAPIServer
|
||||
if apiSrv == nil {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure we always set n.IncomingFiles non-nil so it gets encoded
|
||||
// in JSON to clients. They distinguish between empty and non-nil
|
||||
// to know whether a Notify should be able about files.
|
||||
n.IncomingFiles = apiSrv.taildrop.IncomingFiles()
|
||||
b.mu.Unlock()
|
||||
|
||||
sort.Slice(n.IncomingFiles, func(i, j int) bool {
|
||||
return n.IncomingFiles[i].Started.Before(n.IncomingFiles[j].Started)
|
||||
})
|
||||
|
||||
b.send(n)
|
||||
}
|
||||
|
||||
// setAuthURL sets the authURL and triggers [LocalBackend.popBrowserAuthNow] if the URL has changed.
|
||||
// This method is called when a new authURL is received from the control plane, meaning that either a user
|
||||
// has started a new interactive login (e.g., by running `tailscale login` or clicking Login in the GUI),
|
||||
@@ -5289,21 +5272,9 @@ func (b *LocalBackend) initPeerAPIListener() {
|
||||
return
|
||||
}
|
||||
|
||||
fileRoot := b.fileRootLocked(selfNode.User())
|
||||
if fileRoot == "" {
|
||||
b.logf("peerapi starting without Taildrop directory configured")
|
||||
}
|
||||
|
||||
ps := &peerAPIServer{
|
||||
b: b,
|
||||
taildrop: taildrop.ManagerOptions{
|
||||
Logf: b.logf,
|
||||
Clock: tstime.DefaultClock{Clock: b.clock},
|
||||
State: b.store,
|
||||
Dir: fileRoot,
|
||||
DirectFileMode: b.directFileRoot != "",
|
||||
SendFileNotify: b.sendFileNotify,
|
||||
}.New(),
|
||||
b: b,
|
||||
taildrop: b.newTaildropManager(b.fileRootLocked(selfNode.User())),
|
||||
}
|
||||
if dm, ok := b.sys.DNSManager.GetOK(); ok {
|
||||
ps.resolver = dm.Resolver()
|
||||
@@ -6598,172 +6569,6 @@ func (b *LocalBackend) TestOnlyPublicKeys() (machineKey key.MachinePublic, nodeK
|
||||
return mk, nk
|
||||
}
|
||||
|
||||
func (b *LocalBackend) removeFileWaiter(handle set.Handle) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.fileWaiters, handle)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.fileWaiters.Add(wakeWaiter)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) {
|
||||
b.mu.Lock()
|
||||
apiSrv := b.peerAPIServer
|
||||
b.mu.Unlock()
|
||||
return mayDeref(apiSrv).taildrop.WaitingFiles()
|
||||
}
|
||||
|
||||
// AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done,
|
||||
// waiting for any files to be available.
|
||||
//
|
||||
// On return, exactly one of the results will be non-empty or non-nil,
|
||||
// respectively.
|
||||
func (b *LocalBackend) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 {
|
||||
return ff, err
|
||||
}
|
||||
|
||||
for {
|
||||
gotFile, gotFileCancel := context.WithCancel(context.Background())
|
||||
defer gotFileCancel()
|
||||
|
||||
handle := b.addFileWaiter(gotFileCancel)
|
||||
defer b.removeFileWaiter(handle)
|
||||
|
||||
// Now that we've registered ourselves, check again, in case
|
||||
// of race. Otherwise there's a small window where we could
|
||||
// miss a file arrival and wait forever.
|
||||
if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 {
|
||||
return ff, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-gotFile.Done():
|
||||
if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 {
|
||||
return ff, err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) DeleteFile(name string) error {
|
||||
b.mu.Lock()
|
||||
apiSrv := b.peerAPIServer
|
||||
b.mu.Unlock()
|
||||
return mayDeref(apiSrv).taildrop.DeleteFile(name)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) {
|
||||
b.mu.Lock()
|
||||
apiSrv := b.peerAPIServer
|
||||
b.mu.Unlock()
|
||||
return mayDeref(apiSrv).taildrop.OpenFile(name)
|
||||
}
|
||||
|
||||
// hasCapFileSharing reports whether the current node has the file
|
||||
// sharing capability enabled.
|
||||
func (b *LocalBackend) hasCapFileSharing() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.capFileSharing
|
||||
}
|
||||
|
||||
// FileTargets lists nodes that the current node can send files to.
|
||||
func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
|
||||
var ret []*apitype.FileTarget
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
nm := b.netMap
|
||||
if b.state != ipn.Running || nm == nil {
|
||||
return nil, errors.New("not connected to the tailnet")
|
||||
}
|
||||
if !b.capFileSharing {
|
||||
return nil, errors.New("file sharing not enabled by Tailscale admin")
|
||||
}
|
||||
for _, p := range b.peers {
|
||||
if !b.peerIsTaildropTargetLocked(p) {
|
||||
continue
|
||||
}
|
||||
if p.Hostinfo().OS() == "tvOS" {
|
||||
continue
|
||||
}
|
||||
peerAPI := peerAPIBase(b.netMap, p)
|
||||
if peerAPI == "" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, &apitype.FileTarget{
|
||||
Node: p.AsStruct(),
|
||||
PeerAPIURL: peerAPI,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(ret, func(a, b *apitype.FileTarget) int {
|
||||
return cmp.Compare(a.Node.Name, b.Node.Name)
|
||||
})
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus {
|
||||
if b.state != ipn.Running {
|
||||
return ipnstate.TaildropTargetIpnStateNotRunning
|
||||
}
|
||||
if b.netMap == nil {
|
||||
return ipnstate.TaildropTargetNoNetmapAvailable
|
||||
}
|
||||
if !b.capFileSharing {
|
||||
return ipnstate.TaildropTargetMissingCap
|
||||
}
|
||||
|
||||
if !p.Online().Get() {
|
||||
return ipnstate.TaildropTargetOffline
|
||||
}
|
||||
|
||||
if !p.Valid() {
|
||||
return ipnstate.TaildropTargetNoPeerInfo
|
||||
}
|
||||
if b.netMap.User() != p.User() {
|
||||
// Different user must have the explicit file sharing target capability
|
||||
if p.Addresses().Len() == 0 ||
|
||||
!b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) {
|
||||
return ipnstate.TaildropTargetOwnedByOtherUser
|
||||
}
|
||||
}
|
||||
|
||||
if p.Hostinfo().OS() == "tvOS" {
|
||||
return ipnstate.TaildropTargetUnsupportedOS
|
||||
}
|
||||
if peerAPIBase(b.netMap, p) == "" {
|
||||
return ipnstate.TaildropTargetNoPeerAPI
|
||||
}
|
||||
return ipnstate.TaildropTargetAvailable
|
||||
}
|
||||
|
||||
// peerIsTaildropTargetLocked reports whether p is a valid Taildrop file
|
||||
// recipient from this node according to its ownership and the capabilities in
|
||||
// the netmap.
|
||||
//
|
||||
// b.mu must be locked.
|
||||
func (b *LocalBackend) peerIsTaildropTargetLocked(p tailcfg.NodeView) bool {
|
||||
if b.netMap == nil || !p.Valid() {
|
||||
return false
|
||||
}
|
||||
if b.netMap.User() == p.User() {
|
||||
return true
|
||||
}
|
||||
if p.Addresses().Len() > 0 &&
|
||||
b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) {
|
||||
// Explicitly noted in the netmap ACL caps as a target.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *LocalBackend) peerHasCapLocked(addr netip.Addr, wantCap tailcfg.PeerCapability) bool {
|
||||
return b.peerCapsLocked(addr).HasCapability(wantCap)
|
||||
}
|
||||
@@ -7834,14 +7639,6 @@ func allowedAutoRoute(ipp netip.Prefix) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// mayDeref dereferences p if non-nil, otherwise it returns the zero value.
|
||||
func mayDeref[T any](p *T) (v T) {
|
||||
if p == nil {
|
||||
return v
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
var ErrNoPreferredDERP = errors.New("no preferred DERP, try again later")
|
||||
|
||||
// suggestExitNodeLocked computes a suggestion based on the current netmap and last netcheck report. If
|
||||
|
@@ -575,54 +575,6 @@ func TestSetUseExitNodeEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTargets(t *testing.T) {
|
||||
b := new(LocalBackend)
|
||||
_, err := b.FileTargets()
|
||||
if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want {
|
||||
t.Errorf("before connect: got %q; want %q", got, want)
|
||||
}
|
||||
|
||||
b.netMap = new(netmap.NetworkMap)
|
||||
_, err = b.FileTargets()
|
||||
if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want {
|
||||
t.Errorf("non-running netmap: got %q; want %q", got, want)
|
||||
}
|
||||
|
||||
b.state = ipn.Running
|
||||
_, err = b.FileTargets()
|
||||
if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want {
|
||||
t.Errorf("without cap: got %q; want %q", got, want)
|
||||
}
|
||||
|
||||
b.capFileSharing = true
|
||||
got, err := b.FileTargets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unexpected %d peers", len(got))
|
||||
}
|
||||
|
||||
var peerMap map[tailcfg.NodeID]tailcfg.NodeView
|
||||
mak.NonNil(&peerMap)
|
||||
var nodeID tailcfg.NodeID
|
||||
nodeID = 1234
|
||||
peer := &tailcfg.Node{
|
||||
ID: 1234,
|
||||
Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(),
|
||||
}
|
||||
peerMap[nodeID] = peer.View()
|
||||
b.peers = peerMap
|
||||
got, err = b.FileTargets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unexpected %d peers", len(got))
|
||||
}
|
||||
// (other cases handled by TestPeerAPIBase above)
|
||||
}
|
||||
|
||||
func TestInternalAndExternalInterfaces(t *testing.T) {
|
||||
type interfacePrefix struct {
|
||||
i netmon.Interface
|
||||
|
@@ -15,7 +15,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -37,10 +36,8 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/sockstats"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/httphdr"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
@@ -64,7 +61,7 @@ type peerAPIServer struct {
|
||||
b *LocalBackend
|
||||
resolver peerDNSQueryHandler
|
||||
|
||||
taildrop *taildrop.Manager
|
||||
taildrop *taildrop_Manager
|
||||
}
|
||||
|
||||
func (s *peerAPIServer) listen(ip netip.Addr, ifState *netmon.State) (ln net.Listener, err error) {
|
||||
@@ -232,6 +229,8 @@ type PeerAPIHandler interface {
|
||||
Self() tailcfg.NodeView
|
||||
LocalBackend() *LocalBackend
|
||||
IsSelfUntagged() bool // whether the peer is untagged and the same as this user
|
||||
RemoteAddr() netip.AddrPort
|
||||
Logf(format string, a ...any)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) IsSelfUntagged() bool {
|
||||
@@ -239,7 +238,11 @@ func (h *peerAPIHandler) IsSelfUntagged() bool {
|
||||
}
|
||||
func (h *peerAPIHandler) Peer() tailcfg.NodeView { return h.peerNode }
|
||||
func (h *peerAPIHandler) Self() tailcfg.NodeView { return h.selfNode }
|
||||
func (h *peerAPIHandler) RemoteAddr() netip.AddrPort { return h.remoteAddr }
|
||||
func (h *peerAPIHandler) LocalBackend() *LocalBackend { return h.ps.b }
|
||||
func (h *peerAPIHandler) Logf(format string, a ...any) {
|
||||
h.logf(format, a...)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) logf(format string, a ...any) {
|
||||
h.ps.b.logf("peerapi: "+format, a...)
|
||||
@@ -327,9 +330,18 @@ func RegisterPeerAPIHandler(path string, f func(PeerAPIHandler, http.ResponseWri
|
||||
panic(fmt.Sprintf("duplicate PeerAPI handler %q", path))
|
||||
}
|
||||
peerAPIHandlers[path] = f
|
||||
if strings.HasSuffix(path, "/") {
|
||||
peerAPIHandlerPrefixes[path] = f
|
||||
}
|
||||
}
|
||||
|
||||
var peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path
|
||||
var (
|
||||
peerAPIHandlers = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){} // by URL.Path
|
||||
|
||||
// peerAPIHandlerPrefixes are the subset of peerAPIHandlers where
|
||||
// the map key ends with a slash, indicating a prefix match.
|
||||
peerAPIHandlerPrefixes = map[string]func(PeerAPIHandler, http.ResponseWriter, *http.Request){}
|
||||
)
|
||||
|
||||
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.validatePeerAPIRequest(r); err != nil {
|
||||
@@ -343,12 +355,11 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/v0/put/") {
|
||||
if r.Method == "PUT" {
|
||||
metricPutCalls.Add(1)
|
||||
for pfx, ph := range peerAPIHandlerPrefixes {
|
||||
if strings.HasPrefix(r.URL.Path, pfx) {
|
||||
ph(h, w, r)
|
||||
return
|
||||
}
|
||||
h.handlePeerPut(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/dns-query") {
|
||||
metricDNSCalls.Add(1)
|
||||
@@ -393,6 +404,10 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ph(h, w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" {
|
||||
http.Error(w, "unsupported peerapi path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
who := h.peerUser.DisplayName
|
||||
fmt.Fprintf(w, `<html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -630,15 +645,6 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
fmt.Fprintln(w, "</pre>")
|
||||
}
|
||||
|
||||
// canPutFile reports whether h can put a file ("Taildrop") to this node.
|
||||
func (h *peerAPIHandler) canPutFile() bool {
|
||||
if h.peerNode.UnsignedPeerAPIOnly() {
|
||||
// Unsigned peers can't send files.
|
||||
return false
|
||||
}
|
||||
return h.isSelf || h.peerHasCap(tailcfg.PeerCapabilityFileSharingSend)
|
||||
}
|
||||
|
||||
// canDebug reports whether h can debug this node (goroutines, metrics,
|
||||
// magicsock internal state, etc).
|
||||
func (h *peerAPIHandler) canDebug() bool {
|
||||
@@ -668,110 +674,6 @@ func (h *peerAPIHandler) PeerCaps() tailcfg.PeerCapMap {
|
||||
return h.ps.b.PeerCaps(h.remoteAddr.Addr())
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canPutFile() {
|
||||
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !h.ps.b.hasCapFileSharing() {
|
||||
http.Error(w, taildrop.ErrNoTaildrop.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
rawPath := r.URL.EscapedPath()
|
||||
prefix, ok := strings.CutPrefix(rawPath, "/v0/put/")
|
||||
if !ok {
|
||||
http.Error(w, "misconfigured internals", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
baseName, err := url.PathUnescape(prefix)
|
||||
if err != nil {
|
||||
http.Error(w, taildrop.ErrInvalidFileName.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
id := taildrop.ClientID(h.peerNode.StableID())
|
||||
if prefix == "" {
|
||||
// List all the partial files.
|
||||
files, err := h.ps.taildrop.PartialFiles(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := enc.Encode(files); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
h.logf("json.Encoder.Encode error: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Stream all the block hashes for the specified file.
|
||||
next, close, err := h.ps.taildrop.HashPartialFile(id, baseName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer close()
|
||||
for {
|
||||
switch cs, err := next(); {
|
||||
case err == io.EOF:
|
||||
return
|
||||
case err != nil:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
h.logf("HashPartialFile.next error: %v", err)
|
||||
return
|
||||
default:
|
||||
if err := enc.Encode(cs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
h.logf("json.Encoder.Encode error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "PUT":
|
||||
t0 := h.ps.b.clock.Now()
|
||||
id := taildrop.ClientID(h.peerNode.StableID())
|
||||
|
||||
var offset int64
|
||||
if rangeHdr := r.Header.Get("Range"); rangeHdr != "" {
|
||||
ranges, ok := httphdr.ParseRange(rangeHdr)
|
||||
if !ok || len(ranges) != 1 || ranges[0].Length != 0 {
|
||||
http.Error(w, "invalid Range header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
offset = ranges[0].Start
|
||||
}
|
||||
n, err := h.ps.taildrop.PutFile(taildrop.ClientID(fmt.Sprint(id)), baseName, r.Body, offset, r.ContentLength)
|
||||
switch err {
|
||||
case nil:
|
||||
d := h.ps.b.clock.Since(t0).Round(time.Second / 10)
|
||||
h.logf("got put of %s in %v from %v/%v", approxSize(n), d, h.remoteAddr.Addr(), h.peerNode.ComputedName)
|
||||
io.WriteString(w, "{}\n")
|
||||
case taildrop.ErrNoTaildrop:
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
case taildrop.ErrInvalidFileName:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case taildrop.ErrFileExists:
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "expected method GET or PUT", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func approxSize(n int64) string {
|
||||
if n <= 1<<10 {
|
||||
return "<=1KB"
|
||||
}
|
||||
if n <= 1<<20 {
|
||||
return "<=1MB"
|
||||
}
|
||||
return fmt.Sprintf("~%dMB", n>>20)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeGoroutines(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.canDebug() {
|
||||
http.Error(w, "denied; no debug access", http.StatusForbidden)
|
||||
@@ -1244,7 +1146,6 @@ var (
|
||||
metricInvalidRequests = clientmetric.NewCounter("peerapi_invalid_requests")
|
||||
|
||||
// Non-debug PeerAPI endpoints.
|
||||
metricPutCalls = clientmetric.NewCounter("peerapi_put")
|
||||
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
|
||||
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
|
||||
)
|
||||
|
@@ -4,33 +4,23 @@
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go4.org/netipx"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc"
|
||||
"tailscale.com/appc/appctest"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
@@ -75,56 +65,12 @@ func bodyNotContains(sub string) check {
|
||||
}
|
||||
}
|
||||
|
||||
func fileHasSize(name string, size int) check {
|
||||
return func(t *testing.T, e *peerAPITestEnv) {
|
||||
root := e.ph.ps.taildrop.Dir()
|
||||
if root == "" {
|
||||
t.Errorf("no rootdir; can't check whether %q has size %v", name, size)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
if fi, err := os.Stat(path); err != nil {
|
||||
t.Errorf("fileHasSize(%q, %v): %v", name, size, err)
|
||||
} else if fi.Size() != int64(size) {
|
||||
t.Errorf("file %q has size %v; want %v", name, fi.Size(), size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fileHasContents(name string, want string) check {
|
||||
return func(t *testing.T, e *peerAPITestEnv) {
|
||||
root := e.ph.ps.taildrop.Dir()
|
||||
if root == "" {
|
||||
t.Errorf("no rootdir; can't check contents of %q", name)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Errorf("fileHasContents: %v", err)
|
||||
return
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("file contents = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hexAll(v string) string {
|
||||
var sb strings.Builder
|
||||
for i := range len(v) {
|
||||
fmt.Fprintf(&sb, "%%%02x", v[i])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func TestHandlePeerAPI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isSelf bool // the peer sending the request is owned by us
|
||||
capSharing bool // self node has file sharing capability
|
||||
debugCap bool // self node has debug capability
|
||||
omitRoot bool // don't configure
|
||||
reqs []*http.Request
|
||||
checks []check
|
||||
}{
|
||||
@@ -174,255 +120,6 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
bodyContains("ServeHTTP"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "reject_non_owner_put",
|
||||
isSelf: false,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(http.StatusForbidden),
|
||||
bodyContains("Taildrop disabled"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "owner_without_cap",
|
||||
isSelf: true,
|
||||
capSharing: false,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(http.StatusForbidden),
|
||||
bodyContains("Taildrop disabled"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "owner_with_cap_no_rootdir",
|
||||
omitRoot: true,
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(http.StatusForbidden),
|
||||
bodyContains("Taildrop disabled; no storage directory"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_method",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("POST", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(405),
|
||||
bodyContains("expected method GET or PUT"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_zero_length",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", nil)},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
bodyContains("{}"),
|
||||
fileHasSize("foo", 0),
|
||||
fileHasContents("foo", ""),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_non_zero_length_content_length",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents"))},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
bodyContains("{}"),
|
||||
fileHasSize("foo", len("contents")),
|
||||
fileHasContents("foo", "contents"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_non_zero_length_chunked",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo", struct{ io.Reader }{strings.NewReader("contents")})},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
bodyContains("{}"),
|
||||
fileHasSize("foo", len("contents")),
|
||||
fileHasContents("foo", "contents"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_partial",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.partial", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_deleted",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo.deleted", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_dot",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/.", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_empty",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_slash",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/foo/bar", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_encoded_dot",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_encoded_slash",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_encoded_backslash",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_encoded_dotdot",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "bad_filename_encoded_dotdot_out",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_spaces_and_caps",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Foo Bar.dat"), strings.NewReader("baz"))},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
bodyContains("{}"),
|
||||
fileHasContents("Foo Bar.dat", "baz"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_unicode",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник"))},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
bodyContains("{}"),
|
||||
fileHasContents("Томас и его друзья.mp3", "главный озорник"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_invalid_utf8",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_invalid_null",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%00", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_invalid_non_printable",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/%01", nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_invalid_colon",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "put_invalid_surrounding_whitespace",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil)},
|
||||
checks: checks(
|
||||
httpStatus(400),
|
||||
bodyContains("invalid filename"),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "host-val/bad-ip",
|
||||
isSelf: true,
|
||||
@@ -450,72 +147,6 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
httpStatus(200),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "duplicate_zero_length",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", nil),
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", nil),
|
||||
},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
func(t *testing.T, env *peerAPITestEnv) {
|
||||
got, err := env.ph.ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("WaitingFiles error: %v", err)
|
||||
}
|
||||
want := []apitype.WaitingFile{{Name: "foo", Size: 0}}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "duplicate_non_zero_length_content_length",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")),
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")),
|
||||
},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
func(t *testing.T, env *peerAPITestEnv) {
|
||||
got, err := env.ph.ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("WaitingFiles error: %v", err)
|
||||
}
|
||||
want := []apitype.WaitingFile{{Name: "foo", Size: 8}}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "duplicate_different_files",
|
||||
isSelf: true,
|
||||
capSharing: true,
|
||||
reqs: []*http.Request{
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("fizz")),
|
||||
httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("buzz")),
|
||||
},
|
||||
checks: checks(
|
||||
httpStatus(200),
|
||||
func(t *testing.T, env *peerAPITestEnv) {
|
||||
got, err := env.ph.ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("WaitingFiles error: %v", err)
|
||||
}
|
||||
want := []apitype.WaitingFile{{Name: "foo", Size: 4}, {Name: "foo (1)", Size: 4}}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("WaitingFile mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -544,16 +175,6 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
b: lb,
|
||||
},
|
||||
}
|
||||
var rootDir string
|
||||
if !tt.omitRoot {
|
||||
rootDir = t.TempDir()
|
||||
if e.ph.ps.taildrop == nil {
|
||||
e.ph.ps.taildrop = taildrop.ManagerOptions{
|
||||
Logf: e.logBuf.Logf,
|
||||
Dir: rootDir,
|
||||
}.New()
|
||||
}
|
||||
}
|
||||
for _, req := range tt.reqs {
|
||||
e.rr = httptest.NewRecorder()
|
||||
if req.Host == "example.com" {
|
||||
@@ -564,76 +185,10 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
for _, f := range tt.checks {
|
||||
f(t, &e)
|
||||
}
|
||||
if t.Failed() && rootDir != "" {
|
||||
t.Logf("Contents of %s:", rootDir)
|
||||
des, _ := fs.ReadDir(os.DirFS(rootDir), ".")
|
||||
for _, de := range des {
|
||||
fi, err := de.Info()
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
} else {
|
||||
t.Logf(" %v %5d %s", fi.Mode(), fi.Size(), de.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Windows likes to hold on to file descriptors for some indeterminate
|
||||
// amount of time after you close them and not let you delete them for
|
||||
// a bit. So test that we work around that sufficiently.
|
||||
func TestFileDeleteRace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := &peerAPIServer{
|
||||
b: &LocalBackend{
|
||||
logf: t.Logf,
|
||||
capFileSharing: true,
|
||||
clock: &tstest.Clock{},
|
||||
},
|
||||
taildrop: taildrop.ManagerOptions{
|
||||
Logf: t.Logf,
|
||||
Dir: dir,
|
||||
}.New(),
|
||||
}
|
||||
ph := &peerAPIHandler{
|
||||
isSelf: true,
|
||||
peerNode: (&tailcfg.Node{
|
||||
ComputedName: "some-peer-name",
|
||||
}).View(),
|
||||
selfNode: (&tailcfg.Node{
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.100.100.101/32")},
|
||||
}).View(),
|
||||
ps: ps,
|
||||
}
|
||||
buf := make([]byte, 2<<20)
|
||||
for range 30 {
|
||||
rr := httptest.NewRecorder()
|
||||
ph.ServeHTTP(rr, httptest.NewRequest("PUT", "http://100.100.100.101:123/v0/put/foo.txt", bytes.NewReader(buf[:rand.Intn(len(buf))])))
|
||||
if res := rr.Result(); res.StatusCode != 200 {
|
||||
t.Fatal(res.Status)
|
||||
}
|
||||
wfs, err := ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(wfs) != 1 {
|
||||
t.Fatalf("waiting files = %d; want 1", len(wfs))
|
||||
}
|
||||
|
||||
if err := ps.taildrop.DeleteFile("foo.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wfs, err = ps.taildrop.WaitingFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(wfs) != 0 {
|
||||
t.Fatalf("waiting files = %d; want 0", len(wfs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerAPIReplyToDNSQueries(t *testing.T) {
|
||||
var h peerAPIHandler
|
||||
|
||||
|
@@ -1,16 +1,270 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_taildrop
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/empty"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
func init() {
|
||||
hookSetNotifyFilesWaitingLocked = (*LocalBackend).setNotifyFilesWaitingLocked
|
||||
hookSetPeerStatusTaildropTargetLocked = (*LocalBackend).setPeerStatusTaildropTargetLocked
|
||||
}
|
||||
|
||||
type taildrop_Manager = taildrop.Manager
|
||||
|
||||
func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop.Manager {
|
||||
// TODO(bradfitz): move all this to an ipnext so ipnlocal doesn't need to depend
|
||||
// on taildrop at all.
|
||||
if fileRoot == "" {
|
||||
b.logf("no Taildrop directory configured")
|
||||
}
|
||||
return taildrop.ManagerOptions{
|
||||
Logf: b.logf,
|
||||
Clock: tstime.DefaultClock{Clock: b.clock},
|
||||
State: b.store,
|
||||
Dir: fileRoot,
|
||||
DirectFileMode: b.directFileRoot != "",
|
||||
SendFileNotify: b.sendFileNotify,
|
||||
}.New()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) sendFileNotify() {
|
||||
var n ipn.Notify
|
||||
|
||||
b.mu.Lock()
|
||||
for _, wakeWaiter := range b.fileWaiters {
|
||||
wakeWaiter()
|
||||
}
|
||||
apiSrv := b.peerAPIServer
|
||||
if apiSrv == nil {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
n.IncomingFiles = apiSrv.taildrop.IncomingFiles()
|
||||
b.mu.Unlock()
|
||||
|
||||
b.send(n)
|
||||
}
|
||||
|
||||
// TaildropManager returns the taildrop manager for this backend.
|
||||
//
|
||||
// TODO(bradfitz): as of 2025-04-15, this is a temporary method during
|
||||
// refactoring; the plan is for all taildrop code to leave the ipnlocal package
|
||||
// and move to an extension. Baby steps.
|
||||
func (b *LocalBackend) TaildropManager() (*taildrop.Manager, error) {
|
||||
b.mu.Lock()
|
||||
ps := b.peerAPIServer
|
||||
b.mu.Unlock()
|
||||
if ps == nil {
|
||||
return nil, errors.New("no peer API server initialized")
|
||||
}
|
||||
if ps.taildrop == nil {
|
||||
return nil, errors.New("no taildrop manager initialized")
|
||||
}
|
||||
return ps.taildrop, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) taildropOrNil() *taildrop.Manager {
|
||||
b.mu.Lock()
|
||||
ps := b.peerAPIServer
|
||||
b.mu.Unlock()
|
||||
if ps == nil {
|
||||
return nil
|
||||
}
|
||||
return ps.taildrop
|
||||
}
|
||||
|
||||
func (b *LocalBackend) setNotifyFilesWaitingLocked(n *ipn.Notify) {
|
||||
if ps := b.peerAPIServer; ps != nil {
|
||||
if ps.taildrop.HasFilesWaiting() {
|
||||
n.FilesWaiting = &empty.Message{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) setPeerStatusTaildropTargetLocked(ps *ipnstate.PeerStatus, p tailcfg.NodeView) {
|
||||
ps.TaildropTarget = b.taildropTargetStatus(p)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) removeFileWaiter(handle set.Handle) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.fileWaiters, handle)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) addFileWaiter(wakeWaiter context.CancelFunc) set.Handle {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.fileWaiters.Add(wakeWaiter)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) WaitingFiles() ([]apitype.WaitingFile, error) {
|
||||
return b.taildropOrNil().WaitingFiles()
|
||||
}
|
||||
|
||||
// AwaitWaitingFiles is like WaitingFiles but blocks while ctx is not done,
|
||||
// waiting for any files to be available.
|
||||
//
|
||||
// On return, exactly one of the results will be non-empty or non-nil,
|
||||
// respectively.
|
||||
func (b *LocalBackend) AwaitWaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 {
|
||||
return ff, err
|
||||
}
|
||||
|
||||
for {
|
||||
gotFile, gotFileCancel := context.WithCancel(context.Background())
|
||||
defer gotFileCancel()
|
||||
|
||||
handle := b.addFileWaiter(gotFileCancel)
|
||||
defer b.removeFileWaiter(handle)
|
||||
|
||||
// Now that we've registered ourselves, check again, in case
|
||||
// of race. Otherwise there's a small window where we could
|
||||
// miss a file arrival and wait forever.
|
||||
if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 {
|
||||
return ff, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-gotFile.Done():
|
||||
if ff, err := b.WaitingFiles(); err != nil || len(ff) > 0 {
|
||||
return ff, err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LocalBackend) DeleteFile(name string) error {
|
||||
return b.taildropOrNil().DeleteFile(name)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) {
|
||||
return b.taildropOrNil().OpenFile(name)
|
||||
}
|
||||
|
||||
// HasCapFileSharing reports whether the current node has the file
|
||||
// sharing capability enabled.
|
||||
func (b *LocalBackend) HasCapFileSharing() bool {
|
||||
// TODO(bradfitz): remove this method and all Taildrop/Taildrive
|
||||
// references from LocalBackend as part of tailscale/tailscale#12614.
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.capFileSharing
|
||||
}
|
||||
|
||||
// FileTargets lists nodes that the current node can send files to.
|
||||
func (b *LocalBackend) FileTargets() ([]*apitype.FileTarget, error) {
|
||||
var ret []*apitype.FileTarget
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
nm := b.netMap
|
||||
if b.state != ipn.Running || nm == nil {
|
||||
return nil, errors.New("not connected to the tailnet")
|
||||
}
|
||||
if !b.capFileSharing {
|
||||
return nil, errors.New("file sharing not enabled by Tailscale admin")
|
||||
}
|
||||
for _, p := range b.peers {
|
||||
if !b.peerIsTaildropTargetLocked(p) {
|
||||
continue
|
||||
}
|
||||
if p.Hostinfo().OS() == "tvOS" {
|
||||
continue
|
||||
}
|
||||
peerAPI := peerAPIBase(b.netMap, p)
|
||||
if peerAPI == "" {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, &apitype.FileTarget{
|
||||
Node: p.AsStruct(),
|
||||
PeerAPIURL: peerAPI,
|
||||
})
|
||||
}
|
||||
slices.SortFunc(ret, func(a, b *apitype.FileTarget) int {
|
||||
return cmp.Compare(a.Node.Name, b.Node.Name)
|
||||
})
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) taildropTargetStatus(p tailcfg.NodeView) ipnstate.TaildropTargetStatus {
|
||||
if b.state != ipn.Running {
|
||||
return ipnstate.TaildropTargetIpnStateNotRunning
|
||||
}
|
||||
if b.netMap == nil {
|
||||
return ipnstate.TaildropTargetNoNetmapAvailable
|
||||
}
|
||||
if !b.capFileSharing {
|
||||
return ipnstate.TaildropTargetMissingCap
|
||||
}
|
||||
|
||||
if !p.Online().Get() {
|
||||
return ipnstate.TaildropTargetOffline
|
||||
}
|
||||
|
||||
if !p.Valid() {
|
||||
return ipnstate.TaildropTargetNoPeerInfo
|
||||
}
|
||||
if b.netMap.User() != p.User() {
|
||||
// Different user must have the explicit file sharing target capability
|
||||
if p.Addresses().Len() == 0 ||
|
||||
!b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) {
|
||||
return ipnstate.TaildropTargetOwnedByOtherUser
|
||||
}
|
||||
}
|
||||
|
||||
if p.Hostinfo().OS() == "tvOS" {
|
||||
return ipnstate.TaildropTargetUnsupportedOS
|
||||
}
|
||||
if peerAPIBase(b.netMap, p) == "" {
|
||||
return ipnstate.TaildropTargetNoPeerAPI
|
||||
}
|
||||
return ipnstate.TaildropTargetAvailable
|
||||
}
|
||||
|
||||
// peerIsTaildropTargetLocked reports whether p is a valid Taildrop file
|
||||
// recipient from this node according to its ownership and the capabilities in
|
||||
// the netmap.
|
||||
//
|
||||
// b.mu must be locked.
|
||||
func (b *LocalBackend) peerIsTaildropTargetLocked(p tailcfg.NodeView) bool {
|
||||
if b.netMap == nil || !p.Valid() {
|
||||
return false
|
||||
}
|
||||
if b.netMap.User() == p.User() {
|
||||
return true
|
||||
}
|
||||
if p.Addresses().Len() > 0 &&
|
||||
b.peerHasCapLocked(p.Addresses().At(0).Addr(), tailcfg.PeerCapabilityFileSharingTarget) {
|
||||
// Explicitly noted in the netmap ACL caps as a target.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateOutgoingFiles updates b.outgoingFiles to reflect the given updates and
|
||||
// sends an ipn.Notify with the full list of outgoingFiles.
|
||||
func (b *LocalBackend) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
|
||||
|
12
ipn/ipnlocal/taildrop_omit.go
Normal file
12
ipn/ipnlocal/taildrop_omit.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build ts_omit_taildrop
|
||||
|
||||
package ipnlocal
|
||||
|
||||
type taildrop_Manager = struct{}
|
||||
|
||||
func (b *LocalBackend) newTaildropManager(fileRoot string) *taildrop_Manager {
|
||||
return nil
|
||||
}
|
77
ipn/ipnlocal/taildrop_test.go
Normal file
77
ipn/ipnlocal/taildrop_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_taildrop
|
||||
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest/deptest"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func TestFileTargets(t *testing.T) {
|
||||
b := new(LocalBackend)
|
||||
_, err := b.FileTargets()
|
||||
if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want {
|
||||
t.Errorf("before connect: got %q; want %q", got, want)
|
||||
}
|
||||
|
||||
b.netMap = new(netmap.NetworkMap)
|
||||
_, err = b.FileTargets()
|
||||
if got, want := fmt.Sprint(err), "not connected to the tailnet"; got != want {
|
||||
t.Errorf("non-running netmap: got %q; want %q", got, want)
|
||||
}
|
||||
|
||||
b.state = ipn.Running
|
||||
_, err = b.FileTargets()
|
||||
if got, want := fmt.Sprint(err), "file sharing not enabled by Tailscale admin"; got != want {
|
||||
t.Errorf("without cap: got %q; want %q", got, want)
|
||||
}
|
||||
|
||||
b.capFileSharing = true
|
||||
got, err := b.FileTargets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unexpected %d peers", len(got))
|
||||
}
|
||||
|
||||
var peerMap map[tailcfg.NodeID]tailcfg.NodeView
|
||||
mak.NonNil(&peerMap)
|
||||
var nodeID tailcfg.NodeID
|
||||
nodeID = 1234
|
||||
peer := &tailcfg.Node{
|
||||
ID: 1234,
|
||||
Hostinfo: (&tailcfg.Hostinfo{OS: "tvOS"}).View(),
|
||||
}
|
||||
peerMap[nodeID] = peer.View()
|
||||
b.peers = peerMap
|
||||
got, err = b.FileTargets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unexpected %d peers", len(got))
|
||||
}
|
||||
// (other cases handled by TestPeerAPIBase above)
|
||||
}
|
||||
|
||||
func TestOmitTaildropDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
Tags: "ts_omit_taildrop",
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
BadDeps: map[string]string{
|
||||
"tailscale.com/taildrop": "should be omitted",
|
||||
"tailscale.com/feature/taildrop": "should be omitted",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
Reference in New Issue
Block a user