tailfs: initial implementation

Add a WebDAV-based folder sharing mechanism that is exposed to local clients at
100.100.100.100:8080 and to remote peers via a new peerapi endpoint at
/v0/tailfs.

Add the ability to manage folder sharing via the new 'share' CLI sub-command.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/11127/head
Percy Wegmann 4 months ago committed by Percy Wegmann
parent 2e404b769d
commit 993acf4475

@ -35,6 +35,7 @@ import (
"tailscale.com/paths" "tailscale.com/paths"
"tailscale.com/safesocket" "tailscale.com/safesocket"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/tka" "tailscale.com/tka"
"tailscale.com/types/key" "tailscale.com/types/key"
"tailscale.com/types/tkatype" "tailscale.com/types/tkatype"
@ -1417,6 +1418,48 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion,
return &cv, nil return &cv, nil
} }
// TailfsSetFileServerAddr instructs Tailfs to use the server at addr to access
// the filesystem. This is used on platforms like Windows and MacOS to let
// Tailfs know to use the file server running in the GUI app.
func (lc *LocalClient) TailfsSetFileServerAddr(ctx context.Context, addr string) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/tailfs/fileserver-address", http.StatusCreated, strings.NewReader(addr))
return err
}
// TailfsShareAdd adds the given share to the list of shares that Tailfs will
// serve to remote nodes. If a share with the same name already exists, the
// existing share is replaced/updated.
func (lc *LocalClient) TailfsShareAdd(ctx context.Context, share *tailfs.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/tailfs/shares", http.StatusCreated, jsonBody(share))
return err
}
// TailfsShareRemove removes the share with the given name from the list of
// shares that Tailfs will serve to remote nodes.
func (lc *LocalClient) TailfsShareRemove(ctx context.Context, name string) error {
_, err := lc.send(
ctx,
"DELETE",
"/localapi/v0/tailfs/shares",
http.StatusNoContent,
jsonBody(&tailfs.Share{
Name: name,
}))
return err
}
// TailfsShareList returns the list of shares that Tailfs is currently serving
// to remote nodes.
func (lc *LocalClient) TailfsShareList(ctx context.Context) (map[string]*tailfs.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/tailfs/shares")
if err != nil {
return nil, err
}
var shares map[string]*tailfs.Share
err = json.Unmarshal(result, &shares)
return shares, err
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus. // IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by LocalClient.WatchIPNBus. // It's returned by LocalClient.WatchIPNBus.
// //

@ -9,6 +9,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus 💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
💣 github.com/djherbis/times from tailscale.com/tailfs
github.com/fxamacker/cbor/v2 from tailscale.com/tka github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
L github.com/google/nftables from tailscale.com/util/linuxfw L github.com/google/nftables from tailscale.com/util/linuxfw
@ -19,10 +20,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/tsweb github.com/google/uuid from tailscale.com/tsweb
github.com/hdevalence/ed25519consensus from tailscale.com/tka github.com/hdevalence/ed25519consensus from tailscale.com/tka
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/webdavfs
L github.com/josharian/native from github.com/mdlayher/netlink+ L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+ L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
@ -41,12 +43,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+ W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
github.com/tailscale/gowebdav from tailscale.com/tailfs/webdavfs
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
github.com/tailscale/xnet/webdav from tailscale.com/tailfs+
github.com/tailscale/xnet/webdav/internal/xml from github.com/tailscale/xnet/webdav
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+ L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2 github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+ 💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/wgengine/filter+ go4.org/netipx from tailscale.com/net/tsaddr+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+ W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt
google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+ google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+
@ -86,7 +91,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/derp from tailscale.com/cmd/derper+ tailscale.com/derp from tailscale.com/cmd/derper+
tailscale.com/derp/derphttp from tailscale.com/cmd/derper tailscale.com/derp/derphttp from tailscale.com/cmd/derper
tailscale.com/disco from tailscale.com/derp tailscale.com/disco from tailscale.com/derp
tailscale.com/envknob from tailscale.com/derp+ tailscale.com/envknob from tailscale.com/client/tailscale+
tailscale.com/health from tailscale.com/net/tlsdial tailscale.com/health from tailscale.com/net/tlsdial
tailscale.com/hostinfo from tailscale.com/net/interfaces+ tailscale.com/hostinfo from tailscale.com/net/interfaces+
tailscale.com/ipn from tailscale.com/client/tailscale tailscale.com/ipn from tailscale.com/client/tailscale
@ -94,10 +99,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/metrics from tailscale.com/cmd/derper+ tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/dnscache from tailscale.com/derp/derphttp tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/flowtrack from tailscale.com/net/packet+ tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/net/netns+ 💣 tailscale.com/net/interfaces from tailscale.com/net/netmon+
tailscale.com/net/netaddr from tailscale.com/ipn+ tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netknob from tailscale.com/net/netns tailscale.com/net/netknob from tailscale.com/net/netns
tailscale.com/net/netmon from tailscale.com/net/sockstats+ tailscale.com/net/netmon from tailscale.com/derp/derphttp+
tailscale.com/net/netns from tailscale.com/derp/derphttp tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/tailscale tailscale.com/net/netutil from tailscale.com/client/tailscale
tailscale.com/net/packet from tailscale.com/wgengine/filter tailscale.com/net/packet from tailscale.com/wgengine/filter
@ -110,21 +115,25 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+ 💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
tailscale.com/net/wsconn from tailscale.com/cmd/derper+ tailscale.com/net/wsconn from tailscale.com/cmd/derper+
tailscale.com/paths from tailscale.com/client/tailscale tailscale.com/paths from tailscale.com/client/tailscale
💣 tailscale.com/safesocket from tailscale.com/client/tailscale 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/syncs from tailscale.com/cmd/derper+ tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+ tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tailfs from tailscale.com/client/tailscale
tailscale.com/tailfs/compositefs from tailscale.com/tailfs
tailscale.com/tailfs/shared from tailscale.com/tailfs/compositefs+
tailscale.com/tailfs/webdavfs from tailscale.com/tailfs
tailscale.com/tka from tailscale.com/client/tailscale+ tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/derp+ tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+ tailscale.com/tstime/rate from tailscale.com/derp+
tailscale.com/tsweb from tailscale.com/cmd/derper tailscale.com/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb tailscale.com/tsweb/promvarz from tailscale.com/tsweb
tailscale.com/tsweb/varz from tailscale.com/tsweb+ tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/empty from tailscale.com/ipn tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/cmd/derper+ tailscale.com/types/key from tailscale.com/client/tailscale+
tailscale.com/types/lazy from tailscale.com/version+ tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+ tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn tailscale.com/types/netmap from tailscale.com/ipn
@ -133,9 +142,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/preftype from tailscale.com/ipn tailscale.com/types/preftype from tailscale.com/ipn
tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/ptr from tailscale.com/hostinfo+
tailscale.com/types/structs from tailscale.com/ipn+ tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/tkatype from tailscale.com/types/key+ tailscale.com/types/tkatype from tailscale.com/client/tailscale+
tailscale.com/types/views from tailscale.com/ipn/ipnstate+ tailscale.com/types/views from tailscale.com/ipn+
tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy+ tailscale.com/util/clientmetric from tailscale.com/net/netmon+
tailscale.com/util/cloudenv from tailscale.com/hostinfo+ tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/ctxkey from tailscale.com/tsweb+ tailscale.com/util/ctxkey from tailscale.com/tsweb+
@ -144,22 +153,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/httpm from tailscale.com/client/tailscale tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineread from tailscale.com/hostinfo+ tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/syncs+ tailscale.com/util/mak from tailscale.com/net/interfaces+
tailscale.com/util/multierr from tailscale.com/health+ tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/set from tailscale.com/health+ tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/singleflight from tailscale.com/net/dnscache tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+ tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/syspolicy from tailscale.com/ipn tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/vizerror from tailscale.com/tsweb+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+ W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+ tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+ tailscale.com/version/distro from tailscale.com/envknob+
tailscale.com/wgengine/filter from tailscale.com/types/netmap tailscale.com/wgengine/filter from tailscale.com/types/netmap
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+ golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from tailscale.com/tka golang.org/x/crypto/blake2s from tailscale.com/tka
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls golang.org/x/crypto/chacha20poly1305 from crypto/tls
@ -179,10 +188,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from net+ D golang.org/x/net/route from net+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+ golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ golang.org/x/sync/singleflight from github.com/jellydator/ttlcache/v3
LD golang.org/x/sys/unix from github.com/jsimonetti/rtnetlink/internal/unix+ golang.org/x/sys/cpu from github.com/josharian/native+
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+ LD golang.org/x/sys/unix from github.com/google/nftables+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+ W golang.org/x/sys/windows from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil
golang.org/x/text/secure/bidirule from golang.org/x/net/idna golang.org/x/text/secure/bidirule from golang.org/x/net/idna
@ -194,10 +204,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
bytes from bufio+ bytes from bufio+
cmp from slices+ cmp from slices+
compress/flate from compress/gzip+ compress/flate from compress/gzip+
compress/gzip from internal/profile+ compress/gzip from google.golang.org/protobuf/internal/impl+
container/heap from github.com/jellydator/ttlcache/v3+
container/list from crypto/tls+ container/list from crypto/tls+
context from crypto/tls+ context from crypto/tls+
crypto from crypto/ecdsa+ crypto from crypto/ecdh+
crypto/aes from crypto/ecdsa+ crypto/aes from crypto/ecdsa+
crypto/cipher from crypto/aes+ crypto/cipher from crypto/aes+
crypto/des from crypto/tls+ crypto/des from crypto/tls+
@ -222,14 +233,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
embed from crypto/internal/nistec+ embed from crypto/internal/nistec+
encoding from encoding/json+ encoding from encoding/json+
encoding/asn1 from crypto/x509+ encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka+ encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+ encoding/base64 from encoding/json+
encoding/binary from compress/gzip+ encoding/binary from compress/gzip+
encoding/hex from crypto/x509+ encoding/hex from crypto/x509+
encoding/json from expvar+ encoding/json from expvar+
encoding/pem from crypto/tls+ encoding/pem from crypto/tls+
encoding/xml from github.com/tailscale/gowebdav+
errors from bufio+ errors from bufio+
expvar from tailscale.com/cmd/derper+ expvar from github.com/prometheus/client_golang/prometheus+
flag from tailscale.com/cmd/derper+ flag from tailscale.com/cmd/derper+
fmt from compress/flate+ fmt from compress/flate+
go/token from google.golang.org/protobuf/internal/strs go/token from google.golang.org/protobuf/internal/strs
@ -243,12 +255,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
io/ioutil from github.com/mitchellh/go-ps+ io/ioutil from github.com/mitchellh/go-ps+
log from expvar+ log from expvar+
log/internal from log log/internal from log
maps from tailscale.com/types/views+ maps from tailscale.com/ipn+
math from compress/flate+ math from compress/flate+
math/big from crypto/dsa+ math/big from crypto/dsa+
math/bits from compress/flate+ math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+ math/rand from github.com/mdlayher/netlink+
mime from mime/multipart+ mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http mime/multipart from net/http
mime/quotedprintable from mime/multipart mime/quotedprintable from mime/multipart
net from crypto/tls+ net from crypto/tls+
@ -260,15 +272,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
net/textproto from golang.org/x/net/http/httpguts+ net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+ net/url from crypto/x509+
os from crypto/rand+ os from crypto/rand+
os/exec from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+ os/exec from github.com/coreos/go-iptables/iptables+
os/signal from tailscale.com/cmd/derper os/signal from tailscale.com/cmd/derper
W os/user from tailscale.com/util/winutil W os/user from tailscale.com/util/winutil
path from golang.org/x/crypto/acme/autocert+ path from github.com/prometheus/client_golang/prometheus/internal+
path/filepath from crypto/x509+ path/filepath from crypto/x509+
reflect from crypto/x509+ reflect from crypto/x509+
regexp from internal/profile+ regexp from github.com/coreos/go-iptables/iptables+
regexp/syntax from regexp regexp/syntax from regexp
runtime/debug from golang.org/x/crypto/acme+ runtime/debug from github.com/prometheus/client_golang/prometheus+
runtime/metrics from github.com/prometheus/client_golang/prometheus+ runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof runtime/pprof from net/http/pprof
runtime/trace from net/http/pprof+ runtime/trace from net/http/pprof+

@ -125,6 +125,7 @@ change in the future.
versionCmd, versionCmd,
webCmd, webCmd,
fileCmd, fileCmd,
shareCmd,
bugReportCmd, bugReportCmd,
certCmd, certCmd,
netlockCmd, netlockCmd,

@ -0,0 +1,209 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/tailfs"
)
const (
shareAddUsage = "[ALPHA] share add <name> <path>"
shareRemoveUsage = "[ALPHA] share remove <name>"
shareListUsage = "[ALPHA] share list"
)
var shareCmd = &ffcli.Command{
Name: "share",
ShortHelp: "Share a directory with your tailnet",
ShortUsage: strings.Join([]string{
shareAddUsage,
shareRemoveUsage,
shareListUsage,
}, "\n "),
LongHelp: buildShareLongHelp(),
UsageFunc: usageFuncNoDefaultValues,
Subcommands: []*ffcli.Command{
{
Name: "add",
Exec: runShareAdd,
ShortHelp: "add a share",
UsageFunc: usageFunc,
},
{
Name: "remove",
ShortHelp: "remove a share",
Exec: runShareRemove,
UsageFunc: usageFunc,
},
{
Name: "list",
ShortHelp: "list current shares",
Exec: runShareList,
UsageFunc: usageFunc,
},
},
Exec: func(context.Context, []string) error {
return errors.New("share subcommand required; run 'tailscale share -h' for details")
},
}
// runShareAdd is the entry point for the "tailscale share add" command.
func runShareAdd(ctx context.Context, args []string) error {
if len(args) != 2 {
return fmt.Errorf("usage: tailscale %v", shareAddUsage)
}
name, path := args[0], args[1]
err := localClient.TailfsShareAdd(ctx, &tailfs.Share{
Name: name,
Path: path,
})
if err == nil {
fmt.Printf("Added share %q at %q\n", name, path)
}
return err
}
// runShareRemove is the entry point for the "tailscale share remove" command.
func runShareRemove(ctx context.Context, args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: tailscale %v", shareRemoveUsage)
}
name := args[0]
err := localClient.TailfsShareRemove(ctx, name)
if err == nil {
fmt.Printf("Removed share %q\n", name)
}
return err
}
// runShareList is the entry point for the "tailscale share list" command.
func runShareList(ctx context.Context, args []string) error {
if len(args) != 0 {
return fmt.Errorf("usage: tailscale %v", shareListUsage)
}
sharesMap, err := localClient.TailfsShareList(ctx)
if err != nil {
return err
}
shares := make([]*tailfs.Share, 0, len(sharesMap))
for _, share := range sharesMap {
shares = append(shares, share)
}
sort.Slice(shares, func(i, j int) bool {
return shares[i].Name < shares[j].Name
})
longestName := 4 // "name"
longestPath := 4 // "path"
longestAs := 2 // "as"
for _, share := range shares {
if len(share.Name) > longestName {
longestName = len(share.Name)
}
if len(share.Path) > longestPath {
longestPath = len(share.Path)
}
if len(share.As) > longestAs {
longestAs = len(share.As)
}
}
formatString := fmt.Sprintf("%%-%ds %%-%ds %%s\n", longestName, longestPath)
fmt.Printf(formatString, "name", "path", "as")
fmt.Printf(formatString, strings.Repeat("-", longestName), strings.Repeat("-", longestPath), strings.Repeat("-", longestAs))
for _, share := range shares {
fmt.Printf(formatString, share.Name, share.Path, share.As)
}
return nil
}
func buildShareLongHelp() string {
longHelpAs := ""
if tailfs.AllowShareAs() {
longHelpAs = shareLongHelpAs
}
return fmt.Sprintf(shareLongHelpBase, longHelpAs)
}
var shareLongHelpBase = `Tailscale share allows you to share directories with other machines on your tailnet.
Each share is identified by a name and points to a directory at a specific path. For example, to share the path /Users/me/Documents under the name "docs", you would run:
$ tailscale share add docs /Users/me/Documents
Note that the system forces share names to lowercase to avoid problems with clients that don't support case-sensitive filenames.
Share names may only contain the letters a-z, underscore _, parentheses (), or spaces. Leading and trailing spaces are omitted.
All Tailscale shares have a globally unique path consisting of the tailnet, the machine name and the share name. For example, if the above share was created on the machine "mylaptop" on the tailnet "mydomain.com", the share's path would be:
/mydomain.com/mylaptop/docs
In order to access this share, other machines on the tailnet can connect to the above path on a WebDAV server running at 100.100.100.100:8080, for example:
http://100.100.100.100:8080/mydomain.com/mylaptop/docs
Permissions to access shares are controlled via ACLs. For example, to give yourself read/write access and give the group "home" read-only access to the above share, use the below ACL grants:
{
"src": ["mylogin@domain.com"],
"dst": ["mylaptop's ip address"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["docs"],
"access": "rw"
}]
}
},
{
"src": ["group:home"],
"dst": ["mylaptop"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["docs"],
"access": "ro"
}]
}
}
To categorically give yourself access to all your shares, you can use the below ACL grant:
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"app": {
"tailscale.com/cap/tailfs": [{
"shares": ["*"],
"access": "rw"
}]
}
},
Whenever either you or anyone in the group "home" connects to the share, they connect as if they are using your local machine user. They'll be able to read the same files as your user and if they create files, those files will be owned by your user.%s
You can remove shares by name, for example you could remove the above share by running:
$ tailscale share remove docs
You can get a list of currently published shares by running:
$ tailscale share list`
var shareLongHelpAs = `
If you want a share to be accessed as a different user, you can use sudo to accomplish this. For example, to create the aforementioned share as "theuser", you could run:
$ sudo -u theuser tailscale share add docs /Users/theuser/Documents`

@ -2,13 +2,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519 filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+ W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil/authenticode+ W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
💣 github.com/djherbis/times from tailscale.com/tailfs
github.com/fxamacker/cbor/v2 from tailscale.com/tka github.com/fxamacker/cbor/v2 from tailscale.com/tka
L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
@ -18,17 +19,18 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L github.com/google/nftables/expr from github.com/google/nftables+ L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/util/quarantine+ github.com/google/uuid from tailscale.com/clientupdate+
github.com/gorilla/csrf from tailscale.com/client/web github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/tka+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/webdavfs
L github.com/josharian/native from github.com/mdlayher/netlink+ L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+ L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
💣 github.com/mattn/go-colorable from tailscale.com/cmd/tailscale/cli 💣 github.com/mattn/go-colorable from tailscale.com/cmd/tailscale/cli
💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable+ 💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable+
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
@ -51,18 +53,21 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/gowebdav from tailscale.com/tailfs/webdavfs
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
github.com/tailscale/xnet/webdav from tailscale.com/tailfs+
github.com/tailscale/xnet/webdav/internal/xml from github.com/tailscale/xnet/webdav
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+ L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2 github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/derp+ 💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/wgengine/filter+ go4.org/netipx from tailscale.com/net/tsaddr+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+ W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
nhooyr.io/websocket from tailscale.com/derp/derphttp+ nhooyr.io/websocket from tailscale.com/control/controlhttp+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
@ -71,11 +76,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli
software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12 software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12
tailscale.com from tailscale.com/version tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn+ tailscale.com/atomicfile from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+ tailscale.com/client/tailscale from tailscale.com/client/web+
tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+ tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
tailscale.com/client/web from tailscale.com/cmd/tailscale/cli tailscale.com/client/web from tailscale.com/cmd/tailscale/cli
tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli+ tailscale.com/clientupdate from tailscale.com/client/web+
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/control/controlbase from tailscale.com/control/controlhttp tailscale.com/control/controlbase from tailscale.com/control/controlhttp
@ -84,54 +89,58 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/derp from tailscale.com/derp/derphttp tailscale.com/derp from tailscale.com/derp/derphttp
tailscale.com/derp/derphttp from tailscale.com/net/netcheck tailscale.com/derp/derphttp from tailscale.com/net/netcheck
tailscale.com/disco from tailscale.com/derp tailscale.com/disco from tailscale.com/derp
tailscale.com/envknob from tailscale.com/cmd/tailscale/cli+ tailscale.com/envknob from tailscale.com/client/tailscale+
tailscale.com/health from tailscale.com/net/tlsdial tailscale.com/health from tailscale.com/net/tlsdial
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
tailscale.com/hostinfo from tailscale.com/net/interfaces+ tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+ tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+ tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/licenses from tailscale.com/cmd/tailscale/cli+ tailscale.com/licenses from tailscale.com/client/web+
tailscale.com/metrics from tailscale.com/derp tailscale.com/metrics from tailscale.com/derp
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+ tailscale.com/net/dnscache from tailscale.com/control/controlhttp+
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
tailscale.com/net/flowtrack from tailscale.com/wgengine/filter+ tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscale/cli+ 💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/netaddr from tailscale.com/ipn+ tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli
tailscale.com/net/neterror from tailscale.com/net/netcheck+ tailscale.com/net/neterror from tailscale.com/net/netcheck+
tailscale.com/net/netknob from tailscale.com/net/netns tailscale.com/net/netknob from tailscale.com/net/netns
tailscale.com/net/netmon from tailscale.com/net/sockstats+ tailscale.com/net/netmon from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/netns from tailscale.com/derp/derphttp+ tailscale.com/net/netns from tailscale.com/derp/derphttp+
tailscale.com/net/netutil from tailscale.com/client/tailscale+ tailscale.com/net/netutil from tailscale.com/client/tailscale+
tailscale.com/net/packet from tailscale.com/wgengine/filter+ tailscale.com/net/packet from tailscale.com/wgengine/capture+
tailscale.com/net/ping from tailscale.com/net/netcheck tailscale.com/net/ping from tailscale.com/net/netcheck
tailscale.com/net/portmapper from tailscale.com/net/netcheck+ tailscale.com/net/portmapper from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/sockstats from tailscale.com/control/controlhttp+ tailscale.com/net/sockstats from tailscale.com/control/controlhttp+
tailscale.com/net/stun from tailscale.com/net/netcheck tailscale.com/net/stun from tailscale.com/net/netcheck
L tailscale.com/net/tcpinfo from tailscale.com/derp L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp+ tailscale.com/net/tlsdial from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/tsaddr from tailscale.com/net/interfaces+ tailscale.com/net/tsaddr from tailscale.com/client/web+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+ 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+ tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
tailscale.com/paths from tailscale.com/cmd/tailscale/cli+ tailscale.com/paths from tailscale.com/client/tailscale+
💣 tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+ 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/syncs from tailscale.com/net/netcheck+ tailscale.com/syncs from tailscale.com/cmd/tailscale/cli+
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+ tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tailfs from tailscale.com/client/tailscale+
tailscale.com/tailfs/compositefs from tailscale.com/tailfs
tailscale.com/tailfs/shared from tailscale.com/tailfs/compositefs+
tailscale.com/tailfs/webdavfs from tailscale.com/tailfs
tailscale.com/tka from tailscale.com/client/tailscale+ tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/control/controlhttp+ tailscale.com/tstime from tailscale.com/control/controlhttp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+ tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli+
tailscale.com/types/dnstype from tailscale.com/tailcfg tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/empty from tailscale.com/ipn tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/derp+ tailscale.com/types/key from tailscale.com/client/tailscale+
tailscale.com/types/lazy from tailscale.com/version+ tailscale.com/types/lazy from tailscale.com/util/testenv+
tailscale.com/types/logger from tailscale.com/cmd/tailscale/cli+ tailscale.com/types/logger from tailscale.com/client/web+
tailscale.com/types/netmap from tailscale.com/ipn tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/nettype from tailscale.com/net/netcheck+ tailscale.com/types/nettype from tailscale.com/net/netcheck+
tailscale.com/types/opt from tailscale.com/net/netcheck+ tailscale.com/types/opt from tailscale.com/client/tailscale+
tailscale.com/types/persist from tailscale.com/ipn tailscale.com/types/persist from tailscale.com/ipn
tailscale.com/types/preftype from tailscale.com/cmd/tailscale/cli+ tailscale.com/types/preftype from tailscale.com/cmd/tailscale/cli+
tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/ptr from tailscale.com/hostinfo+
@ -146,29 +155,29 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+ tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/groupmember from tailscale.com/client/web tailscale.com/util/groupmember from tailscale.com/client/web
tailscale.com/util/httpm from tailscale.com/client/tailscale+ tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineread from tailscale.com/net/interfaces+ tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/net/netcheck+ tailscale.com/util/mak from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/multierr from tailscale.com/control/controlhttp+ tailscale.com/util/multierr from tailscale.com/control/controlhttp+
tailscale.com/util/must from tailscale.com/cmd/tailscale/cli+ tailscale.com/util/must from tailscale.com/clientupdate/distsign+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli
tailscale.com/util/set from tailscale.com/health+ tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/singleflight from tailscale.com/net/dnscache+ tailscale.com/util/singleflight from tailscale.com/net/dnscache+
tailscale.com/util/slicesx from tailscale.com/net/dnscache+ tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/ipn tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
tailscale.com/util/vizerror from tailscale.com/types/ipproto+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/hostinfo+ 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
tailscale.com/version from tailscale.com/cmd/tailscale/cli+ tailscale.com/version from tailscale.com/client/web+
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+ tailscale.com/version/distro from tailscale.com/client/web+
tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli
tailscale.com/wgengine/filter from tailscale.com/types/netmap tailscale.com/wgengine/filter from tailscale.com/types/netmap
golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+ golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from tailscale.com/control/controlbase+ golang.org/x/crypto/blake2s from tailscale.com/clientupdate/distsign+
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls+ golang.org/x/crypto/chacha20poly1305 from crypto/tls+
golang.org/x/crypto/cryptobyte from crypto/ecdsa+ golang.org/x/crypto/cryptobyte from crypto/ecdsa+
@ -188,18 +197,19 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/net/http2/hpack from net/http golang.org/x/net/http2/hpack from net/http
golang.org/x/net/icmp from tailscale.com/net/ping golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/ipv4 from golang.org/x/net/icmp+ golang.org/x/net/ipv4 from github.com/miekg/dns+
golang.org/x/net/ipv6 from golang.org/x/net/icmp+ golang.org/x/net/ipv6 from github.com/miekg/dns+
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from net+ D golang.org/x/net/route from net+
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials
golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/tailscale/cli golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/tailscale/cli
golang.org/x/oauth2/internal from golang.org/x/oauth2+ golang.org/x/oauth2/internal from golang.org/x/oauth2+
golang.org/x/sync/errgroup from tailscale.com/derp+ golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ golang.org/x/sync/singleflight from github.com/jellydator/ttlcache/v3
LD golang.org/x/sys/unix from tailscale.com/net/netns+ golang.org/x/sys/cpu from github.com/josharian/native+
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+ LD golang.org/x/sys/unix from github.com/google/nftables+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+ W golang.org/x/sys/windows from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil
golang.org/x/text/secure/bidirule from golang.org/x/net/idna golang.org/x/text/secure/bidirule from golang.org/x/net/idna
@ -209,14 +219,15 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/time/rate from tailscale.com/cmd/tailscale/cli+ golang.org/x/time/rate from tailscale.com/cmd/tailscale/cli+
archive/tar from tailscale.com/clientupdate archive/tar from tailscale.com/clientupdate
bufio from compress/flate+ bufio from compress/flate+
bytes from bufio+ bytes from archive/tar+
cmp from slices+ cmp from slices+
compress/flate from compress/gzip+ compress/flate from compress/gzip+
compress/gzip from net/http+ compress/gzip from net/http+
compress/zlib from image/png+ compress/zlib from debug/pe+
container/heap from github.com/jellydator/ttlcache/v3+
container/list from crypto/tls+ container/list from crypto/tls+
context from crypto/tls+ context from crypto/tls+
crypto from crypto/ecdsa+ crypto from crypto/ecdh+
crypto/aes from crypto/ecdsa+ crypto/aes from crypto/ecdsa+
crypto/cipher from crypto/aes+ crypto/cipher from crypto/aes+
crypto/des from crypto/tls+ crypto/des from crypto/tls+
@ -234,16 +245,16 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
crypto/sha256 from crypto/tls+ crypto/sha256 from crypto/tls+
crypto/sha512 from crypto/ecdsa+ crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/aes+ crypto/subtle from crypto/aes+
crypto/tls from github.com/tcnksm/go-httpstat+ crypto/tls from github.com/miekg/dns+
crypto/x509 from crypto/tls+ crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+ crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com/cmd/tailscale/cli+ embed from crypto/internal/nistec+
encoding from encoding/json+ encoding from encoding/gob+
encoding/asn1 from crypto/x509+ encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka+ encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+ encoding/base64 from encoding/json+
encoding/binary from compress/gzip+ encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie encoding/gob from github.com/gorilla/securecookie
@ -251,64 +262,64 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
encoding/json from expvar+ encoding/json from expvar+
encoding/pem from crypto/tls+ encoding/pem from crypto/tls+
encoding/xml from github.com/tailscale/goupnp+ encoding/xml from github.com/tailscale/goupnp+
errors from bufio+ errors from archive/tar+
expvar from tailscale.com/derp+ expvar from tailscale.com/derp+
flag from github.com/peterbourgon/ff/v3+ flag from github.com/peterbourgon/ff/v3+
fmt from compress/flate+ fmt from archive/tar+
hash from crypto+ hash from compress/zlib+
hash/adler32 from compress/zlib hash/adler32 from compress/zlib
hash/crc32 from compress/gzip+ hash/crc32 from compress/gzip+
hash/maphash from go4.org/mem hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnstate+ html from html/template+
html/template from github.com/gorilla/csrf html/template from github.com/gorilla/csrf
image from github.com/skip2/go-qrcode+ image from github.com/skip2/go-qrcode+
image/color from github.com/skip2/go-qrcode+ image/color from github.com/skip2/go-qrcode+
image/png from github.com/skip2/go-qrcode image/png from github.com/skip2/go-qrcode
io from bufio+ io from archive/tar+
io/fs from crypto/x509+ io/fs from archive/tar+
io/ioutil from github.com/godbus/dbus/v5+ io/ioutil from github.com/godbus/dbus/v5+
log from expvar+ log from expvar+
log/internal from log log/internal from log
maps from tailscale.com/types/views+ maps from tailscale.com/clientupdate+
math from compress/flate+ math from archive/tar+
math/big from crypto/dsa+ math/big from crypto/dsa+
math/bits from compress/flate+ math/bits from compress/flate+
math/rand from math/big+ math/rand from github.com/mdlayher/netlink+
mime from mime/multipart+ mime from github.com/tailscale/xnet/webdav+
mime/multipart from net/http mime/multipart from net/http
mime/quotedprintable from mime/multipart mime/quotedprintable from mime/multipart
net from crypto/tls+ net from crypto/tls+
net/http from expvar+ net/http from expvar+
net/http/cgi from tailscale.com/cmd/tailscale/cli net/http/cgi from tailscale.com/cmd/tailscale/cli
net/http/httptrace from github.com/tcnksm/go-httpstat+ net/http/httptrace from github.com/tcnksm/go-httpstat+
net/http/httputil from tailscale.com/cmd/tailscale/cli+ net/http/httputil from tailscale.com/client/web+
net/http/internal from net/http+ net/http/internal from net/http+
net/netip from net+ net/netip from go4.org/netipx+
net/textproto from golang.org/x/net/http/httpguts+ net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+ net/url from crypto/x509+
os from crypto/rand+ os from crypto/rand+
os/exec from github.com/toqueteos/webbrowser+ os/exec from github.com/coreos/go-iptables/iptables+
os/signal from tailscale.com/cmd/tailscale/cli os/signal from tailscale.com/cmd/tailscale/cli
os/user from tailscale.com/util/groupmember+ os/user from archive/tar+
path from html/template+ path from archive/tar+
path/filepath from crypto/x509+ path/filepath from archive/tar+
reflect from crypto/x509+ reflect from archive/tar+
regexp from github.com/tailscale/goupnp/httpu+ regexp from github.com/coreos/go-iptables/iptables+
regexp/syntax from regexp regexp/syntax from regexp
runtime/debug from tailscale.com/util/singleflight+ runtime/debug from golang.org/x/sync/singleflight+
runtime/trace from testing runtime/trace from testing
slices from tailscale.com/cmd/tailscale/cli+ slices from tailscale.com/client/web+
sort from compress/flate+ sort from archive/tar+
strconv from compress/flate+ strconv from archive/tar+
strings from bufio+ strings from archive/tar+
sync from compress/flate+ sync from archive/tar+
sync/atomic from context+ sync/atomic from context+
syscall from crypto/rand+ syscall from archive/tar+
testing from tailscale.com/util/syspolicy testing from tailscale.com/util/syspolicy
text/tabwriter from github.com/peterbourgon/ff/v3/ffcli+ text/tabwriter from github.com/peterbourgon/ff/v3/ffcli+
text/template from html/template text/template from html/template
text/template/parse from html/template+ text/template/parse from html/template+
time from compress/gzip+ time from archive/tar+
unicode from bytes+ unicode from bytes+
unicode/utf16 from encoding/asn1+ unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+ unicode/utf8 from bufio+

@ -6,7 +6,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
LD github.com/anmitsu/go-shlex from tailscale.com/tempfork/gliderlabs/ssh LD github.com/anmitsu/go-shlex from tailscale.com/tempfork/gliderlabs/ssh
L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/middleware+ L github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
L github.com/aws/aws-sdk-go-v2/aws/arn from tailscale.com/ipn/store/awsstore L github.com/aws/aws-sdk-go-v2/aws/arn from tailscale.com/ipn/store/awsstore
L github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/ssm+ L github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/ssm+
L github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+ L github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
@ -87,10 +87,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com W github.com/dblohm7/wingoes/internal from github.com/dblohm7/wingoes/com
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+ W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/osdiag+
LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture
💣 github.com/djherbis/times from tailscale.com/tailfs
github.com/fxamacker/cbor/v2 from tailscale.com/tka github.com/fxamacker/cbor/v2 from tailscale.com/tka
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+ W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+ L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+ github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+
L github.com/google/nftables from tailscale.com/util/linuxfw L github.com/google/nftables from tailscale.com/util/linuxfw
@ -102,12 +103,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/google/uuid from tailscale.com/clientupdate github.com/google/uuid from tailscale.com/clientupdate
github.com/gorilla/csrf from tailscale.com/client/web github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/tka+ github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4 L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4 L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4 L github.com/insomniacslk/dhcp/rfc1035label from github.com/insomniacslk/dhcp/dhcpv4
github.com/jellydator/ttlcache/v3 from tailscale.com/tailfs/webdavfs
L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/jmespath/go-jmespath from github.com/aws/aws-sdk-go-v2/service/ssm
L github.com/josharian/native from github.com/mdlayher/netlink+ L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+ L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
@ -115,14 +117,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/klauspost/compress from github.com/klauspost/compress/zstd github.com/klauspost/compress from github.com/klauspost/compress/zstd
github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0 github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0
github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd
github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/zstd+ github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
github.com/klauspost/compress/zstd from tailscale.com/smallzstd github.com/klauspost/compress/zstd from tailscale.com/smallzstd
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
LD github.com/kr/fs from github.com/pkg/sftp LD github.com/kr/fs from github.com/pkg/sftp
L github.com/mdlayher/genetlink from tailscale.com/net/tstun L github.com/mdlayher/genetlink from tailscale.com/net/tstun
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+ L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L github.com/mdlayher/sdnotify from tailscale.com/util/systemd L github.com/mdlayher/sdnotify from tailscale.com/util/systemd
@ -153,8 +155,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/gowebdav from tailscale.com/tailfs/webdavfs
github.com/tailscale/hujson from tailscale.com/ipn/conffile github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+ L 💣 github.com/tailscale/netlink from tailscale.com/net/routetable+
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+
W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn
@ -166,6 +169,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/wireguard-go/rwcancel from github.com/tailscale/wireguard-go/device+ github.com/tailscale/wireguard-go/rwcancel from github.com/tailscale/wireguard-go/device+
github.com/tailscale/wireguard-go/tai64n from github.com/tailscale/wireguard-go/device github.com/tailscale/wireguard-go/tai64n from github.com/tailscale/wireguard-go/device
💣 github.com/tailscale/wireguard-go/tun from github.com/tailscale/wireguard-go/device+ 💣 github.com/tailscale/wireguard-go/tun from github.com/tailscale/wireguard-go/device+
github.com/tailscale/xnet/webdav from tailscale.com/tailfs+
github.com/tailscale/xnet/webdav/internal/xml from github.com/tailscale/xnet/webdav
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh
L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4 L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4
@ -173,11 +178,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+ L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2 github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/control/controlbase+ 💣 go4.org/mem from tailscale.com/client/tailscale+
go4.org/netipx from tailscale.com/ipn/ipnlocal+ go4.org/netipx from inet.af/wf+
W 💣 golang.zx2c4.com/wintun from github.com/tailscale/wireguard-go/tun+ W 💣 golang.zx2c4.com/wintun from github.com/tailscale/wireguard-go/tun+
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/dns+ W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/cmd/tailscaled+
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/tcpip+ gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/buffer+
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/buffer
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+ 💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip+
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs
@ -189,9 +194,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 gvisor.dev/gvisor/pkg/sleep from gvisor.dev/gvisor/pkg/tcpip/transport/tcp 💣 gvisor.dev/gvisor/pkg/sleep from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+ 💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/linewriter+ 💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/atomicbitops+
💣 gvisor.dev/gvisor/pkg/sync/locking from gvisor.dev/gvisor/pkg/tcpip/stack 💣 gvisor.dev/gvisor/pkg/sync/locking from gvisor.dev/gvisor/pkg/tcpip/stack
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+ gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet from tailscale.com/wgengine/netstack gvisor.dev/gvisor/pkg/tcpip/adapters/gonet from tailscale.com/wgengine/netstack
💣 gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+ 💣 gvisor.dev/gvisor/pkg/tcpip/checksum from gvisor.dev/gvisor/pkg/buffer+
gvisor.dev/gvisor/pkg/tcpip/hash/jenkins from gvisor.dev/gvisor/pkg/tcpip/stack+ gvisor.dev/gvisor/pkg/tcpip/hash/jenkins from gvisor.dev/gvisor/pkg/tcpip/stack+
@ -207,20 +212,20 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+ gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+ gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/header/parse+ 💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/internal/network+ gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/raw+ gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
gvisor.dev/gvisor/pkg/tcpip/transport/internal/noop from gvisor.dev/gvisor/pkg/tcpip/transport/raw gvisor.dev/gvisor/pkg/tcpip/transport/internal/noop from gvisor.dev/gvisor/pkg/tcpip/transport/raw
gvisor.dev/gvisor/pkg/tcpip/transport/packet from gvisor.dev/gvisor/pkg/tcpip/transport/raw gvisor.dev/gvisor/pkg/tcpip/transport/packet from gvisor.dev/gvisor/pkg/tcpip/transport/raw
gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/udp+ gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
💣 gvisor.dev/gvisor/pkg/tcpip/transport/tcp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ 💣 gvisor.dev/gvisor/pkg/tcpip/transport/tcp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack
gvisor.dev/gvisor/pkg/tcpip/transport/udp from tailscale.com/net/tstun+ gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+ gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
inet.af/peercred from tailscale.com/ipn/ipnauth inet.af/peercred from tailscale.com/ipn/ipnauth
W 💣 inet.af/wf from tailscale.com/wf W 💣 inet.af/wf from tailscale.com/wf
nhooyr.io/websocket from tailscale.com/derp/derphttp+ nhooyr.io/websocket from tailscale.com/control/controlhttp+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
@ -228,119 +233,123 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/appc from tailscale.com/ipn/ipnlocal tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/atomicfile from tailscale.com/ipn+ tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/tailscale from tailscale.com/derp+ tailscale.com/client/tailscale from tailscale.com/client/web+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+ tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
tailscale.com/client/web from tailscale.com/ipn/ipnlocal tailscale.com/client/web from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal+ tailscale.com/clientupdate from tailscale.com/client/web+
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+ tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlbase from tailscale.com/control/controlclient+ tailscale.com/control/controlbase from tailscale.com/control/controlclient+
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+ tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlhttp from tailscale.com/control/controlclient tailscale.com/control/controlhttp from tailscale.com/control/controlclient
tailscale.com/control/controlknobs from tailscale.com/control/controlclient+ tailscale.com/control/controlknobs from tailscale.com/control/controlclient+
tailscale.com/derp from tailscale.com/derp/derphttp+ tailscale.com/derp from tailscale.com/derp/derphttp+
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+ tailscale.com/derp/derphttp from tailscale.com/cmd/tailscaled+
tailscale.com/disco from tailscale.com/derp+ tailscale.com/disco from tailscale.com/derp+
tailscale.com/doctor from tailscale.com/ipn/ipnlocal tailscale.com/doctor from tailscale.com/ipn/ipnlocal
💣 tailscale.com/doctor/permissions from tailscale.com/ipn/ipnlocal 💣 tailscale.com/doctor/permissions from tailscale.com/ipn/ipnlocal
tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal
tailscale.com/envknob from tailscale.com/control/controlclient+ tailscale.com/envknob from tailscale.com/client/tailscale+
tailscale.com/health from tailscale.com/control/controlclient+ tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
tailscale.com/hostinfo from tailscale.com/control/controlclient+ tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+ tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+ tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/store from tailscale.com/cmd/tailscaled+
L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
tailscale.com/ipn/store/mem from tailscale.com/ipn/store+ tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
L tailscale.com/kube from tailscale.com/ipn/store/kubestore L tailscale.com/kube from tailscale.com/ipn/store/kubestore
tailscale.com/licenses from tailscale.com/client/web tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+ tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
tailscale.com/logtail from tailscale.com/control/controlclient+ tailscale.com/logtail from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+ tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/filch from tailscale.com/logpolicy+ tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
tailscale.com/metrics from tailscale.com/derp+ tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/connstats from tailscale.com/net/tstun+ tailscale.com/net/connstats from tailscale.com/net/tstun+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+ tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver+ tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+ tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
tailscale.com/net/dns/resolver from tailscale.com/net/dns tailscale.com/net/dns/resolver from tailscale.com/net/dns
tailscale.com/net/dnscache from tailscale.com/control/controlclient+ tailscale.com/net/dnscache from tailscale.com/control/controlclient+
tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+ tailscale.com/net/dnsfallback from tailscale.com/cmd/tailscaled+
tailscale.com/net/flowtrack from tailscale.com/net/packet+ tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/control/controlclient+ 💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscaled+
tailscale.com/net/netaddr from tailscale.com/ipn+ tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
tailscale.com/net/netknob from tailscale.com/net/netns+ tailscale.com/net/netknob from tailscale.com/logpolicy+
tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+ tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+
tailscale.com/net/netns from tailscale.com/derp/derphttp+ tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/net/netstat from tailscale.com/portlist W 💣 tailscale.com/net/netstat from tailscale.com/portlist
tailscale.com/net/netutil from tailscale.com/ipn/ipnlocal+ tailscale.com/net/netutil from tailscale.com/client/tailscale+
tailscale.com/net/packet from tailscale.com/net/tstun+ tailscale.com/net/packet from tailscale.com/net/connstats+
tailscale.com/net/packet/checksum from tailscale.com/net/tstun tailscale.com/net/packet/checksum from tailscale.com/net/tstun
tailscale.com/net/ping from tailscale.com/net/netcheck+ tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper from tailscale.com/net/netcheck+ tailscale.com/net/portmapper from tailscale.com/ipn/localapi+
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/routetable from tailscale.com/doctor/routetable tailscale.com/net/routetable from tailscale.com/doctor/routetable
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
tailscale.com/net/sockstats from tailscale.com/control/controlclient+ tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/net/netcheck+ tailscale.com/net/stun from tailscale.com/ipn/localapi+
L tailscale.com/net/tcpinfo from tailscale.com/derp L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+ tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tsaddr from tailscale.com/ipn+ tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+ tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+
💣 tailscale.com/net/tshttpproxy from tailscale.com/control/controlclient+ 💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+ tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
tailscale.com/net/tstun/table from tailscale.com/net/tstun tailscale.com/net/tstun/table from tailscale.com/net/tstun
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+ tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
tailscale.com/paths from tailscale.com/ipn/ipnlocal+ tailscale.com/paths from tailscale.com/client/tailscale+
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal 💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
tailscale.com/posture from tailscale.com/ipn/ipnlocal tailscale.com/posture from tailscale.com/ipn/ipnlocal
tailscale.com/proxymap from tailscale.com/tsd+ tailscale.com/proxymap from tailscale.com/tsd+
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+ 💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/smallzstd from tailscale.com/control/controlclient+ tailscale.com/smallzstd from tailscale.com/control/controlclient+
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
tailscale.com/syncs from tailscale.com/net/netcheck+ tailscale.com/syncs from tailscale.com/cmd/tailscaled+
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+ tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+ tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+
tailscale.com/tailfs from tailscale.com/client/tailscale+
tailscale.com/tailfs/compositefs from tailscale.com/tailfs
tailscale.com/tailfs/shared from tailscale.com/tailfs/compositefs+
tailscale.com/tailfs/webdavfs from tailscale.com/tailfs
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table 💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
tailscale.com/tka from tailscale.com/ipn/ipnlocal+ tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tsd from tailscale.com/cmd/tailscaled+ tailscale.com/tsd from tailscale.com/cmd/tailscaled+
tailscale.com/tstime from tailscale.com/wgengine/magicsock+ tailscale.com/tstime from tailscale.com/control/controlclient+
tailscale.com/tstime/mono from tailscale.com/net/tstun+ tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+ tailscale.com/tstime/rate from tailscale.com/derp+
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+ tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+ tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+ tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/control/controlbase+ tailscale.com/types/key from tailscale.com/client/tailscale+
tailscale.com/types/lazy from tailscale.com/version+ tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+
tailscale.com/types/logger from tailscale.com/control/controlclient+ tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logid from tailscale.com/logtail+ tailscale.com/types/logid from tailscale.com/cmd/tailscaled+
tailscale.com/types/netlogtype from tailscale.com/net/connstats+ tailscale.com/types/netlogtype from tailscale.com/net/connstats+
tailscale.com/types/netmap from tailscale.com/control/controlclient+ tailscale.com/types/netmap from tailscale.com/control/controlclient+
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock+ tailscale.com/types/nettype from tailscale.com/ipn/localapi+
tailscale.com/types/opt from tailscale.com/client/tailscale+ tailscale.com/types/opt from tailscale.com/client/tailscale+
tailscale.com/types/persist from tailscale.com/control/controlclient+ tailscale.com/types/persist from tailscale.com/control/controlclient+
tailscale.com/types/preftype from tailscale.com/ipn+ tailscale.com/types/preftype from tailscale.com/ipn+
tailscale.com/types/ptr from tailscale.com/hostinfo+ tailscale.com/types/ptr from tailscale.com/control/controlclient+
tailscale.com/types/structs from tailscale.com/control/controlclient+ tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/tkatype from tailscale.com/tka+ tailscale.com/types/tkatype from tailscale.com/tka+
tailscale.com/types/views from tailscale.com/ipn/ipnlocal+ tailscale.com/types/views from tailscale.com/ipn/ipnlocal+
@ -350,58 +359,58 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/ctxkey from tailscale.com/ipn/ipnlocal+ tailscale.com/util/ctxkey from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+ L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
tailscale.com/util/dnsname from tailscale.com/hostinfo+ tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/execqueue from tailscale.com/control/controlclient+ tailscale.com/util/execqueue from tailscale.com/control/controlclient+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth+ tailscale.com/util/groupmember from tailscale.com/client/web+
💣 tailscale.com/util/hashx from tailscale.com/util/deephash 💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+
tailscale.com/util/httpm from tailscale.com/client/tailscale+ tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineread from tailscale.com/hostinfo+ tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns+ L tailscale.com/util/linuxfw from tailscale.com/net/netns+
tailscale.com/util/mak from tailscale.com/control/controlclient+ tailscale.com/util/mak from tailscale.com/control/controlclient+
tailscale.com/util/multierr from tailscale.com/control/controlclient+ tailscale.com/util/multierr from tailscale.com/cmd/tailscaled+
tailscale.com/util/must from tailscale.com/logpolicy+ tailscale.com/util/must from tailscale.com/clientupdate/distsign+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+ 💣 tailscale.com/util/osdiag from tailscale.com/cmd/tailscaled+
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+ tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
tailscale.com/util/osuser from tailscale.com/ssh/tailssh+ tailscale.com/util/osuser from tailscale.com/ipn/localapi+
tailscale.com/util/race from tailscale.com/net/dns/resolver tailscale.com/util/race from tailscale.com/net/dns/resolver
tailscale.com/util/racebuild from tailscale.com/logpolicy tailscale.com/util/racebuild from tailscale.com/logpolicy
tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+ tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+
tailscale.com/util/ringbuffer from tailscale.com/wgengine/magicsock tailscale.com/util/ringbuffer from tailscale.com/wgengine/magicsock
tailscale.com/util/set from tailscale.com/health+ tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+ tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/net/dnscache+ tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+ tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+ tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+ tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+ tailscale.com/util/uniq from tailscale.com/ipn/ipnlocal+
tailscale.com/util/vizerror from tailscale.com/types/ipproto+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+ 💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/util/osdiag+ W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
tailscale.com/version from tailscale.com/derp+ tailscale.com/version from tailscale.com/client/web+
tailscale.com/version/distro from tailscale.com/hostinfo+ tailscale.com/version/distro from tailscale.com/client/web+
W tailscale.com/wf from tailscale.com/cmd/tailscaled W tailscale.com/wf from tailscale.com/cmd/tailscaled
tailscale.com/wgengine from tailscale.com/ipn/ipnlocal+ tailscale.com/wgengine from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/capture from tailscale.com/ipn/ipnlocal+ tailscale.com/wgengine/capture from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+ tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/netlog from tailscale.com/wgengine tailscale.com/wgengine/netlog from tailscale.com/wgengine
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+ tailscale.com/wgengine/router from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+ tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine 💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine
tailscale.com/wgengine/wglog from tailscale.com/wgengine tailscale.com/wgengine/wglog from tailscale.com/wgengine
W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router
golang.org/x/crypto/argon2 from tailscale.com/tka golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+ golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf+ LD golang.org/x/crypto/blowfish from github.com/tailscale/golang-x-crypto/ssh/internal/bcrypt_pbkdf+
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+ golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+
golang.org/x/crypto/chacha20poly1305 from crypto/tls+ golang.org/x/crypto/chacha20poly1305 from crypto/tls+
golang.org/x/crypto/cryptobyte from crypto/ecdsa+ golang.org/x/crypto/cryptobyte from crypto/ecdsa+
@ -412,9 +421,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device+ golang.org/x/crypto/poly1305 from github.com/tailscale/wireguard-go/device+
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh+ LD golang.org/x/crypto/ssh from github.com/pkg/sftp+
golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+ golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
golang.org/x/exp/maps from tailscale.com/wgengine/magicsock+ golang.org/x/exp/maps from tailscale.com/appc+
golang.org/x/net/bpf from github.com/mdlayher/genetlink+ golang.org/x/net/bpf from github.com/mdlayher/genetlink+
golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from golang.org/x/net/http2+ golang.org/x/net/http/httpguts from golang.org/x/net/http2+
@ -424,15 +433,16 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/net/http2/hpack from golang.org/x/net/http2+ golang.org/x/net/http2/hpack from golang.org/x/net/http2+
golang.org/x/net/icmp from tailscale.com/net/ping golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/idna from golang.org/x/net/http/httpguts+ golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+ golang.org/x/net/ipv4 from github.com/miekg/dns+
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+ golang.org/x/net/ipv6 from github.com/miekg/dns+
golang.org/x/net/proxy from tailscale.com/net/netns golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from net+ D golang.org/x/net/route from net+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+ golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+ golang.org/x/sync/singleflight from github.com/jellydator/ttlcache/v3
LD golang.org/x/sys/unix from github.com/insomniacslk/dhcp/interfaces+ golang.org/x/sys/cpu from github.com/josharian/native+
W golang.org/x/sys/windows from github.com/go-ole/go-ole+ LD golang.org/x/sys/unix from github.com/google/nftables+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+ W golang.org/x/sys/windows from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/registry from github.com/dblohm7/wingoes+
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+ W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
W golang.org/x/sys/windows/svc/eventlog from tailscale.com/cmd/tailscaled W golang.org/x/sys/windows/svc/eventlog from tailscale.com/cmd/tailscaled
W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled+ W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled+
@ -441,18 +451,18 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+ golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
golang.org/x/text/unicode/norm from golang.org/x/net/idna golang.org/x/text/unicode/norm from golang.org/x/net/idna
golang.org/x/time/rate from gvisor.dev/gvisor/pkg/tcpip/stack+ golang.org/x/time/rate from gvisor.dev/gvisor/pkg/log+
archive/tar from tailscale.com/clientupdate archive/tar from tailscale.com/clientupdate
bufio from compress/flate+ bufio from compress/flate+
bytes from bufio+ bytes from archive/tar+
cmp from slices+ cmp from slices+
compress/flate from compress/gzip+ compress/flate from compress/gzip+
compress/gzip from golang.org/x/net/http2+ compress/gzip from golang.org/x/net/http2+
W compress/zlib from debug/pe W compress/zlib from debug/pe
container/heap from gvisor.dev/gvisor/pkg/tcpip/transport/tcp container/heap from github.com/jellydator/ttlcache/v3+
container/list from crypto/tls+ container/list from crypto/tls+
context from crypto/tls+ context from crypto/tls+
crypto from crypto/ecdsa+ crypto from crypto/ecdh+
crypto/aes from crypto/ecdsa+ crypto/aes from crypto/ecdsa+
crypto/cipher from crypto/aes+ crypto/cipher from crypto/aes+
crypto/des from crypto/tls+ crypto/des from crypto/tls+
@ -470,46 +480,46 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
crypto/sha256 from crypto/tls+ crypto/sha256 from crypto/tls+
crypto/sha512 from crypto/ecdsa+ crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/aes+ crypto/subtle from crypto/aes+
crypto/tls from github.com/tcnksm/go-httpstat+ crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
crypto/x509 from crypto/tls+ crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+ crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com+ embed from crypto/internal/nistec+
encoding from encoding/json+ encoding from encoding/gob+
encoding/asn1 from crypto/x509+ encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka+ encoding/base32 from github.com/fxamacker/cbor/v2+
encoding/base64 from encoding/json+ encoding/base64 from encoding/json+
encoding/binary from compress/gzip+ encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+ encoding/hex from crypto/x509+
encoding/json from expvar+ encoding/json from expvar+
encoding/pem from crypto/tls+ encoding/pem from crypto/tls+
encoding/xml from github.com/tailscale/goupnp+ encoding/xml from github.com/aws/aws-sdk-go-v2/aws/protocol/xml+
errors from bufio+ errors from archive/tar+
expvar from tailscale.com/derp+ expvar from tailscale.com/derp+
flag from net/http/httptest+ flag from net/http/httptest+
fmt from compress/flate+ fmt from archive/tar+
hash from crypto+ hash from compress/zlib+
hash/adler32 from compress/zlib+ hash/adler32 from compress/zlib+
hash/crc32 from compress/gzip+ hash/crc32 from compress/gzip+
hash/fnv from tailscale.com/wgengine/magicsock hash/fnv from tailscale.com/wgengine/magicsock
hash/maphash from go4.org/mem hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnlocal+ html from html/template+
html/template from github.com/gorilla/csrf html/template from github.com/gorilla/csrf
io from bufio+ io from archive/tar+
io/fs from crypto/x509+ io/fs from archive/tar+
io/ioutil from github.com/godbus/dbus/v5+ io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
log from expvar+ log from expvar+
log/internal from log log/internal from log
LD log/syslog from tailscale.com/ssh/tailssh LD log/syslog from tailscale.com/ssh/tailssh
maps from tailscale.com/types/views+ maps from tailscale.com/clientupdate+
math from compress/flate+ math from archive/tar+
math/big from crypto/dsa+ math/big from crypto/dsa+
math/bits from compress/flate+ math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+ math/rand from github.com/mdlayher/netlink+
mime from mime/multipart+ mime from github.com/tailscale/xnet/webdav+
mime/multipart from net/http mime/multipart from net/http
mime/quotedprintable from mime/multipart mime/quotedprintable from mime/multipart
net from crypto/tls+ net from crypto/tls+
@ -520,32 +530,32 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
net/http/internal from net/http+ net/http/internal from net/http+
net/http/pprof from tailscale.com/cmd/tailscaled+ net/http/pprof from tailscale.com/cmd/tailscaled+
net/netip from github.com/tailscale/wireguard-go/conn+ net/netip from github.com/tailscale/wireguard-go/conn+
net/textproto from golang.org/x/net/http/httpguts+ net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
net/url from crypto/x509+ net/url from crypto/x509+
os from crypto/rand+ os from crypto/rand+
os/exec from github.com/coreos/go-iptables/iptables+ os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+
os/signal from tailscale.com/cmd/tailscaled os/signal from tailscale.com/cmd/tailscaled
os/user from github.com/godbus/dbus/v5+ os/user from archive/tar+
path from github.com/godbus/dbus/v5+ path from archive/tar+
path/filepath from crypto/x509+ path/filepath from archive/tar+
reflect from crypto/x509+ reflect from archive/tar+
regexp from github.com/coreos/go-iptables/iptables+ regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn+
regexp/syntax from regexp regexp/syntax from regexp
runtime/debug from github.com/klauspost/compress/zstd+ runtime/debug from github.com/aws/aws-sdk-go-v2/internal/sync/singleflight+
runtime/pprof from tailscale.com/ipn/ipnlocal+ runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof+ runtime/trace from net/http/pprof+
slices from tailscale.com/wgengine/magicsock+ slices from tailscale.com/appc+
sort from compress/flate+ sort from archive/tar+
strconv from compress/flate+ strconv from archive/tar+
strings from bufio+ strings from archive/tar+
sync from compress/flate+ sync from archive/tar+
sync/atomic from context+ sync/atomic from context+
syscall from crypto/rand+ syscall from archive/tar+
testing from tailscale.com/util/syspolicy testing from tailscale.com/util/syspolicy
text/tabwriter from runtime/pprof text/tabwriter from runtime/pprof
text/template from html/template text/template from html/template
text/template/parse from html/template+ text/template/parse from html/template+
time from compress/gzip+ time from archive/tar+
unicode from bytes+ unicode from bytes+
unicode/utf16 from crypto/x509+ unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+ unicode/utf8 from bufio+

@ -52,6 +52,7 @@ import (
"tailscale.com/paths" "tailscale.com/paths"
"tailscale.com/safesocket" "tailscale.com/safesocket"
"tailscale.com/syncs" "tailscale.com/syncs"
"tailscale.com/tailfs"
"tailscale.com/tsd" "tailscale.com/tsd"
"tailscale.com/tsweb/varz" "tailscale.com/tsweb/varz"
"tailscale.com/types/flagtype" "tailscale.com/types/flagtype"
@ -135,11 +136,12 @@ var (
createBIRDClient func(string) (wgengine.BIRDClient, error) // non-nil on some platforms createBIRDClient func(string) (wgengine.BIRDClient, error) // non-nil on some platforms
) )
var subCommands = map[string]*func([]string) error{ var subCommands = map[string]func([]string) error{
"install-system-daemon": &installSystemDaemon, "install-system-daemon": installSystemDaemon,
"uninstall-system-daemon": &uninstallSystemDaemon, "uninstall-system-daemon": uninstallSystemDaemon,
"debug": &debugModeFunc, "debug": debugModeFunc,
"be-child": &beChildFunc, "be-child": beChild,
"serve-tailfs": serveTailfs,
} }
var beCLI func() // non-nil if CLI is linked in var beCLI func() // non-nil if CLI is linked in
@ -171,12 +173,12 @@ func main() {
if len(os.Args) > 1 { if len(os.Args) > 1 {
sub := os.Args[1] sub := os.Args[1]
if fp, ok := subCommands[sub]; ok { if fn, ok := subCommands[sub]; ok {
if *fp == nil { if fn == nil {
log.SetFlags(0) log.SetFlags(0)
log.Fatalf("%s not available on %v", sub, runtime.GOOS) log.Fatalf("%s not available on %v", sub, runtime.GOOS)
} }
if err := (*fp)(os.Args[2:]); err != nil { if err := fn(os.Args[2:]); err != nil {
log.SetFlags(0) log.SetFlags(0)
log.Fatal(err) log.Fatal(err)
} }
@ -628,6 +630,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
Dialer: sys.Dialer.Get(), Dialer: sys.Dialer.Get(),
SetSubsystem: sys.Set, SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(), ControlKnobs: sys.ControlKnobs(),
EnableTailfs: true,
} }
onlyNetstack = name == "userspace-networking" onlyNetstack = name == "userspace-networking"
@ -730,6 +733,7 @@ func runDebugServer(mux *http.ServeMux, addr string) {
} }
func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) { func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) {
tfs, _ := sys.TailfsForLocal.GetOK()
ret, err := netstack.Create(logf, ret, err := netstack.Create(logf,
sys.Tun.Get(), sys.Tun.Get(),
sys.Engine.Get(), sys.Engine.Get(),
@ -737,6 +741,7 @@ func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) {
sys.Dialer.Get(), sys.Dialer.Get(),
sys.DNSManager.Get(), sys.DNSManager.Get(),
sys.ProxyMapper(), sys.ProxyMapper(),
tfs,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -792,8 +797,6 @@ func mustStartProxyListeners(socksAddr, httpAddr string) (socksListener, httpLis
return socksListener, httpListener return socksListener, httpListener
} }
var beChildFunc = beChild
func beChild(args []string) error { func beChild(args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New("missing mode argument") return errors.New("missing mode argument")
@ -806,6 +809,33 @@ func beChild(args []string) error {
return f(args[1:]) return f(args[1:])
} }
// serveTailfs serves one or more tailfs on localhost using the WebDAV
// protocol. On UNIX and MacOS tailscaled environment, tailfs spawns child
// tailscaled processes in serve-tailfs mode in order to access the fliesystem
// as specific (usually unprivileged) users.
//
// serveTailfs prints the address on which it's listening to stdout so that the
// parent process knows where to connect to.
func serveTailfs(args []string) error {
if len(args) == 0 {
return errors.New("missing shares")
}
if len(args)%2 != 0 {
return errors.New("need <sharename> <path> pairs")
}
s, err := tailfs.NewFileServer()
if err != nil {
return fmt.Errorf("unable to start tailfs FileServer: %v", err)
}
shares := make(map[string]string)
for i := 0; i < len(args); i += 2 {
shares[args[i]] = args[i+1]
}
s.SetShares(shares)
fmt.Printf("%v\n", s.Addr())
return s.Serve()
}
// dieOnPipeReadErrorOfFD reads from the pipe named by fd and exit the process // dieOnPipeReadErrorOfFD reads from the pipe named by fd and exit the process
// when the pipe becomes readable. We use this in tests as a somewhat more // when the pipe becomes readable. We use this in tests as a somewhat more
// portable mechanism for the Linux PR_SET_PDEATHSIG, which we wish existed on // portable mechanism for the Linux PR_SET_PDEATHSIG, which we wish existed on

@ -110,7 +110,7 @@ func newIPN(jsConfig js.Value) map[string]any {
} }
sys.Set(eng) sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
if err != nil { if err != nil {
log.Fatalf("netstack.Create: %v", err) log.Fatalf("netstack.Create: %v", err)
} }

@ -120,4 +120,4 @@
in in
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system); flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
} }
# nix-direnv cache busting line: sha256-OfwliH0EmrGoOVog8b27F0p5+foElOrRZkOtGR4+Sts= # nix-direnv cache busting line: sha256-eci4f6golU1eIQOezplA+I+gmOfof40ktIdpr0v/uMc=

@ -22,6 +22,7 @@ require (
github.com/dave/patsy v0.0.0-20210517141501-957256f50cba github.com/dave/patsy v0.0.0-20210517141501-957256f50cba
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e
github.com/djherbis/times v1.6.0
github.com/dsnet/try v0.0.3 github.com/dsnet/try v0.0.3
github.com/evanw/esbuild v0.19.11 github.com/evanw/esbuild v0.19.11
github.com/frankban/quicktest v1.14.6 github.com/frankban/quicktest v1.14.6
@ -41,6 +42,7 @@ require (
github.com/iancoleman/strcase v0.3.0 github.com/iancoleman/strcase v0.3.0
github.com/illarion/gonotify v1.0.1 github.com/illarion/gonotify v1.0.1
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2
github.com/jellydator/ttlcache/v3 v3.1.0
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86
github.com/jsimonetti/rtnetlink v1.4.0 github.com/jsimonetti/rtnetlink v1.4.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
@ -65,11 +67,13 @@ require (
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61 github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9
github.com/tc-hib/winres v0.2.1 github.com/tc-hib/winres v0.2.1
github.com/tcnksm/go-httpstat v0.2.0 github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0 github.com/toqueteos/webbrowser v1.2.0

@ -1 +1 @@
sha256-OfwliH0EmrGoOVog8b27F0p5+foElOrRZkOtGR4+Sts= sha256-eci4f6golU1eIQOezplA+I+gmOfof40ktIdpr0v/uMc=

@ -244,6 +244,8 @@ github.com/denis-tingaikin/go-header v0.4.3 h1:tEaZKAlqql6SKCY++utLmkPLd6K8IBM20
github.com/denis-tingaikin/go-header v0.4.3/go.mod h1:0wOCWuN71D5qIgE2nz9KrKmuYBAC2Mra5RassOIQ2/c= github.com/denis-tingaikin/go-header v0.4.3/go.mod h1:0wOCWuN71D5qIgE2nz9KrKmuYBAC2Mra5RassOIQ2/c=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/cli v25.0.0+incompatible h1:zaimaQdnX7fYWFqzN88exE9LDEvRslexpFowZBX6GoQ= github.com/docker/cli v25.0.0+incompatible h1:zaimaQdnX7fYWFqzN88exE9LDEvRslexpFowZBX6GoQ=
github.com/docker/cli v25.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v25.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
@ -530,6 +532,8 @@ github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jgautheron/goconst v1.5.1 h1:HxVbL1MhydKs8R8n/HE5NPvzfaYmQJA3o879lE4+WcM= github.com/jgautheron/goconst v1.5.1 h1:HxVbL1MhydKs8R8n/HE5NPvzfaYmQJA3o879lE4+WcM=
github.com/jgautheron/goconst v1.5.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= github.com/jgautheron/goconst v1.5.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4=
@ -863,6 +867,8 @@ github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 h1:U0J2C
github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio=
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8=
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126 h1:EBLH+PeC3efXmUi82yEMxjlcKhDwAUZTi0tIT4Q8oTg=
github.com/tailscale/gowebdav v0.0.0-20240130173557-d49b872b5126/go.mod h1:UCbnLJ2ebWLs28V9ubpXbq4Qx3e0q1TVoM1AC3Z2b40=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 h1:93cvKHbvsPK3MKfFTvR00d0b0R0bzRKBW9yrj813fhI= github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 h1:93cvKHbvsPK3MKfFTvR00d0b0R0bzRKBW9yrj813fhI=
@ -873,6 +879,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61 h1:G
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ= github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ=
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9 h1:81P7rjnikHKTJ75EkjppvbwUfKHDHYk6LJpO5PZy8pA=
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
@ -1145,6 +1153,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

@ -65,7 +65,8 @@ const (
NotifyInitialPrefs // if set, the first Notify message (sent immediately) will contain the current Prefs NotifyInitialPrefs // if set, the first Notify message (sent immediately) will contain the current Prefs
NotifyInitialNetMap // if set, the first Notify message (sent immediately) will contain the current NetMap NotifyInitialNetMap // if set, the first Notify message (sent immediately) will contain the current NetMap
NotifyNoPrivateKeys // if set, private keys that would normally be sent in updates are zeroed out NotifyNoPrivateKeys // if set, private keys that would normally be sent in updates are zeroed out
NotifyInitialTailfsShares // if set, the first Notify message (sent immediately) will contain the current Tailfs Shares
) )
// Notify is a communication from a backend (e.g. tailscaled) to a frontend // Notify is a communication from a backend (e.g. tailscaled) to a frontend
@ -121,6 +122,12 @@ type Notify struct {
// is available. // is available.
ClientVersion *tailcfg.ClientVersion `json:",omitempty"` ClientVersion *tailcfg.ClientVersion `json:",omitempty"`
// Full set of current TailfsShares that we're publishing as name->path.
// Some client applications, like the MacOS and Windows clients, will
// listen for updates to this and handle serving these shares under the
// identity of the unprivileged user that is running the application.
TailfsShares map[string]string `json:",omitempty"`
// type is mirrored in xcode/Shared/IPN.swift // type is mirrored in xcode/Shared/IPN.swift
} }

@ -67,6 +67,7 @@ import (
"tailscale.com/syncs" "tailscale.com/syncs"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/taildrop" "tailscale.com/taildrop"
"tailscale.com/tailfs"
"tailscale.com/tka" "tailscale.com/tka"
"tailscale.com/tsd" "tailscale.com/tsd"
"tailscale.com/tstime" "tailscale.com/tstime"
@ -287,6 +288,9 @@ type LocalBackend struct {
serveListeners map[netip.AddrPort]*localListener // listeners for local serve traffic serveListeners map[netip.AddrPort]*localListener // listeners for local serve traffic
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy
tailfsListeners map[netip.AddrPort]*localListener // listeners for local tailfs traffic
tailfsForRemote *tailfs.FileSystemForRemote
// statusLock must be held before calling statusChanged.Wait() or // statusLock must be held before calling statusChanged.Wait() or
// statusChanged.Broadcast(). // statusChanged.Broadcast().
statusLock sync.Mutex statusLock sync.Mutex
@ -428,6 +432,15 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
} }
} }
// initialize Tailfs shares from saved state
b.mu.Lock()
b.tailfsForRemote = tailfs.NewFileSystemForRemote(logf)
shares, err := b.tailfsGetSharesLocked()
b.mu.Unlock()
if err == nil && len(shares) > 0 {
b.tailfsForRemote.SetShares(shares)
}
return b, nil return b, nil
} }
@ -915,6 +928,7 @@ func (b *LocalBackend) WhoIs(ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.
var zero tailcfg.NodeView var zero tailcfg.NodeView
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
nid, ok := b.nodeByAddr[ipp.Addr()] nid, ok := b.nodeByAddr[ipp.Addr()]
if !ok { if !ok {
var ip netip.Addr var ip netip.Addr
@ -2254,7 +2268,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
b.mu.Lock() b.mu.Lock()
b.activeWatchSessions.Add(sessionID) b.activeWatchSessions.Add(sessionID)
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialTailfsShares
if mask&initialBits != 0 { if mask&initialBits != 0 {
ini = &ipn.Notify{Version: version.Long()} ini = &ipn.Notify{Version: version.Long()}
if mask&ipn.NotifyInitialState != 0 { if mask&ipn.NotifyInitialState != 0 {
@ -2270,6 +2284,17 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
if mask&ipn.NotifyInitialNetMap != 0 { if mask&ipn.NotifyInitialNetMap != 0 {
ini.NetMap = b.netMap ini.NetMap = b.netMap
} }
if mask&ipn.NotifyInitialTailfsShares != 0 && b.tailfsSharingEnabledLocked() {
shares, err := b.tailfsGetSharesLocked()
if err != nil {
b.logf("unable to notify initial tailfs shares: %v", err)
} else {
ini.TailfsShares = make(map[string]string, len(shares))
for _, share := range shares {
ini.TailfsShares[share.Name] = share.Path
}
}
}
} }
handle := b.notifyWatchers.Add(&watchSession{ch, sessionID}) handle := b.notifyWatchers.Add(&watchSession{ch, sessionID})
@ -3312,6 +3337,14 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
if dst.Port() == webClientPort && b.ShouldRunWebClient() { if dst.Port() == webClientPort && b.ShouldRunWebClient() {
return b.handleWebClientConn, opts return b.handleWebClientConn, opts
} }
if dst.Port() == TailfsLocalPort {
fs, ok := b.sys.TailfsForLocal.GetOK()
if ok {
return func(conn net.Conn) error {
return fs.HandleConn(conn, conn.RemoteAddr())
}, opts
}
}
if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port { if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port {
return func(c net.Conn) error { return func(c net.Conn) error {
b.handlePeerAPIConn(src, dst, c) b.handlePeerAPIConn(src, dst, c)
@ -4608,6 +4641,11 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
delete(b.nodeByAddr, k) delete(b.nodeByAddr, k)
} }
} }
if b.tailfsSharingEnabledLocked() {
b.updateTailfsPeersLocked(nm)
b.tailfsNotifyCurrentSharesLocked()
}
} }
func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) { func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) {
@ -4615,14 +4653,17 @@ func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) {
b.peers = nil b.peers = nil
return return
} }
// First pass, mark everything unwanted. // First pass, mark everything unwanted.
for k := range b.peers { for k := range b.peers {
b.peers[k] = tailcfg.NodeView{} b.peers[k] = tailcfg.NodeView{}
} }
// Second pass, add everything wanted. // Second pass, add everything wanted.
for _, p := range nm.Peers { for _, p := range nm.Peers {
mak.Set(&b.peers, p.ID(), p) mak.Set(&b.peers, p.ID(), p)
} }
// Third pass, remove deleted things. // Third pass, remove deleted things.
for k, v := range b.peers { for k, v := range b.peers {
if !v.Valid() { if !v.Valid() {
@ -4631,6 +4672,28 @@ func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) {
} }
} }
// tailfsTransport is an http.RoundTripper that uses the latest value of
// b.Dialer().PeerAPITransport() for each round trip and imposes a short
// dial timeout to avoid hanging on connecting to offline/unreachable hosts.
type tailfsTransport struct {
b *LocalBackend
}
func (t *tailfsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// dialTimeout is fairly aggressive to avoid hangs on contacting offline or
// unreachable hosts.
dialTimeout := 1 * time.Second // TODO(oxtoacart): tune this
tr := t.b.Dialer().PeerAPITransport().Clone()
dialContext := tr.DialContext
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
ctxWithTimeout, cancel := context.WithTimeout(ctx, dialTimeout)
defer cancel()
return dialContext(ctxWithTimeout, network, addr)
}
return tr.RoundTrip(req)
}
// setDebugLogsByCapabilityLocked sets debug logging based on the self node's // setDebugLogsByCapabilityLocked sets debug logging based on the self node's
// capabilities in the provided NetMap. // capabilities in the provided NetMap.
func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) { func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) {
@ -4703,6 +4766,10 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
} }
} }
if !b.sys.IsNetstack() {
b.updateTailfsListenersLocked()
}
b.reloadServeConfigLocked(prefs) b.reloadServeConfigLocked(prefs)
if b.serveConfig.Valid() { if b.serveConfig.Valid() {
servePorts := make([]uint16, 0, 3) servePorts := make([]uint16, 0, 3)

@ -803,7 +803,7 @@ func TestWatchNotificationsCallbacks(t *testing.T) {
// tests LocalBackend.updateNetmapDeltaLocked // tests LocalBackend.updateNetmapDeltaLocked
func TestUpdateNetmapDelta(t *testing.T) { func TestUpdateNetmapDelta(t *testing.T) {
var b LocalBackend b := newTestLocalBackend(t)
if b.updateNetmapDeltaLocked(nil) { if b.updateNetmapDeltaLocked(nil) {
t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap") t.Errorf("updateNetmapDeltaLocked() = true, want false with nil netmap")
} }

@ -38,12 +38,17 @@ import (
"tailscale.com/net/sockstats" "tailscale.com/net/sockstats"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/taildrop" "tailscale.com/taildrop"
"tailscale.com/tailfs"
"tailscale.com/types/views" "tailscale.com/types/views"
"tailscale.com/util/clientmetric" "tailscale.com/util/clientmetric"
"tailscale.com/util/httphdr" "tailscale.com/util/httphdr"
"tailscale.com/wgengine/filter" "tailscale.com/wgengine/filter"
) )
const (
tailfsPrefix = "/v0/tailfs"
)
var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, string) error var initListenConfig func(*net.ListenConfig, netip.Addr, *interfaces.State, string) error
// addH2C is non-nil on platforms where we want to add H2C // addH2C is non-nil on platforms where we want to add H2C
@ -317,6 +322,10 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleDNSQuery(w, r) h.handleDNSQuery(w, r)
return return
} }
if strings.HasPrefix(r.URL.Path, tailfsPrefix) {
h.handleServeTailfs(w, r)
return
}
switch r.URL.Path { switch r.URL.Path {
case "/v0/goroutines": case "/v0/goroutines":
h.handleServeGoroutines(w, r) h.handleServeGoroutines(w, r)
@ -626,7 +635,11 @@ func (h *peerAPIHandler) canIngress() bool {
} }
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool { func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
return h.ps.b.PeerCaps(h.remoteAddr.Addr()).HasCapability(wantCap) return h.peerCaps().HasCapability(wantCap)
}
func (h *peerAPIHandler) peerCaps() tailcfg.PeerCapMap {
return h.ps.b.PeerCaps(h.remoteAddr.Addr())
} }
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) { func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
@ -1090,6 +1103,41 @@ func writePrettyDNSReply(w io.Writer, res []byte) (err error) {
return nil return nil
} }
func (h *peerAPIHandler) handleServeTailfs(w http.ResponseWriter, r *http.Request) {
if !h.ps.b.TailfsSharingEnabled() {
http.Error(w, "tailfs not enabled", http.StatusNotFound)
return
}
capsMap := h.peerCaps()
tailfsCaps, ok := capsMap[tailcfg.PeerCapabilityTailfs]
if !ok {
http.Error(w, "tailfs not permitted", http.StatusForbidden)
return
}
rawPerms := make([][]byte, 0, len(tailfsCaps))
for _, cap := range tailfsCaps {
rawPerms = append(rawPerms, []byte(cap))
}
p, err := tailfs.ParsePermissions(rawPerms)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.ps.b.mu.Lock()
fs := h.ps.b.tailfsForRemote
h.ps.b.mu.Unlock()
if fs == nil {
http.Error(w, "tailfs not enabled", http.StatusNotFound)
return
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, tailfsPrefix)
fs.ServeHTTPWithPerms(p, w, r)
}
// newFakePeerAPIListener creates a new net.Listener that acts like // newFakePeerAPIListener creates a new net.Listener that acts like
// it's listening on the provided IP address and on TCP port 1. // it's listening on the provided IP address and on TCP port 1.
// //

@ -62,7 +62,7 @@ type serveHTTPContext struct {
// //
// This is not used in userspace-networking mode. // This is not used in userspace-networking mode.
// //
// localListener is used by tailscale serve (TCP only) as well as the built-in web client. // localListener is used by tailscale serve (TCP only), the built-in web client and tailfs.
// Most serve traffic and peer traffic for the web client are intercepted by netstack. // Most serve traffic and peer traffic for the web client are intercepted by netstack.
// This listener exists purely for connections from the machine itself, as that goes via the kernel, // This listener exists purely for connections from the machine itself, as that goes via the kernel,
// so we need to be in the kernel's listening/routing tables. // so we need to be in the kernel's listening/routing tables.

@ -0,0 +1,318 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"os"
"regexp"
"strings"
"time"
"tailscale.com/ipn"
"tailscale.com/logtail/backoff"
"tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
)
const (
// TailfsLocalPort is the port on which the Tailfs listens for location
// connections on quad 100.
TailfsLocalPort = 8080
tailfsSharesStateKey = ipn.StateKey("_tailfs-shares")
)
var (
shareNameRegex = regexp.MustCompile(`^[a-z0-9_\(\) ]+$`)
errInvalidShareName = errors.New("Share names may only contain the letters a-z, underscore _, parentheses (), or spaces")
)
// TailfsSharingEnabled reports whether sharing to remote nodes via tailfs is
// enabled. This is currently based on checking for the tailfs:share node
// attribute.
func (b *LocalBackend) TailfsSharingEnabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.tailfsSharingEnabledLocked()
}
func (b *LocalBackend) tailfsSharingEnabledLocked() bool {
return b.netMap != nil && b.netMap.SelfNode.HasCap(tailcfg.NodeAttrsTailfsSharingEnabled)
}
// TailfsSetFileServerAddr tells tailfs to use the given address for connecting
// to the tailfs.FileServer that's exposing local files as an unprivileged
// user.
func (b *LocalBackend) TailfsSetFileServerAddr(addr string) error {
b.mu.Lock()
fs := b.tailfsForRemote
b.mu.Unlock()
if fs == nil {
return errors.New("tailfs not enabled")
}
fs.SetFileServerAddr(addr)
return nil
}
// TailfsAddShare adds the given share if no share with that name exists, or
// replaces the existing share if one with the same name already exists.
// To avoid potential incompatibilities across file systems, share names are
// limited to alphanumeric characters and the underscore _.
func (b *LocalBackend) TailfsAddShare(share *tailfs.Share) error {
var err error
share.Name, err = normalizeShareName(share.Name)
if err != nil {
return err
}
b.mu.Lock()
shares, err := b.tailfsAddShareLocked(share)
b.mu.Unlock()
if err != nil {
return err
}
b.tailfsNotifyShares(shares)
return nil
}
// normalizeShareName normalizes the given share name and returns an error if
// it contains any disallowed characters.
func normalizeShareName(name string) (string, error) {
// Force all share names to lowercase to avoid potential incompatibilities
// with clients that don't support case-sensitive filenames.
name = strings.ToLower(name)
// Trim whitespace
name = strings.TrimSpace(name)
if !shareNameRegex.MatchString(name) {
return "", errInvalidShareName
}
return name, nil
}
func (b *LocalBackend) tailfsAddShareLocked(share *tailfs.Share) (map[string]string, error) {
if b.tailfsForRemote == nil {
return nil, errors.New("tailfs not enabled")
}
shares, err := b.tailfsGetSharesLocked()
if err != nil {
return nil, err
}
shares[share.Name] = share
data, err := json.Marshal(shares)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
err = b.store.WriteState(tailfsSharesStateKey, data)
if err != nil {
return nil, fmt.Errorf("write state: %w", err)
}
b.tailfsForRemote.SetShares(shares)
return shareNameMap(shares), nil
}
// TailfsRemoveShare removes the named share. Share names are forced to
// lowercase.
func (b *LocalBackend) TailfsRemoveShare(name string) error {
// Force all share names to lowercase to avoid potential incompatibilities
// with clients that don't support case-sensitive filenames.
name = strings.ToLower(name)
b.mu.Lock()
shares, err := b.tailfsRemoveShareLocked(name)
b.mu.Unlock()
if err != nil {
return err
}
b.tailfsNotifyShares(shares)
return nil
}
func (b *LocalBackend) tailfsRemoveShareLocked(name string) (map[string]string, error) {
if b.tailfsForRemote == nil {
return nil, errors.New("tailfs not enabled")
}
shares, err := b.tailfsGetSharesLocked()
if err != nil {
return nil, err
}
_, shareExists := shares[name]
if !shareExists {
return nil, os.ErrNotExist
}
delete(shares, name)
data, err := json.Marshal(shares)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
err = b.store.WriteState(tailfsSharesStateKey, data)
if err != nil {
return nil, fmt.Errorf("write state: %w", err)
}
b.tailfsForRemote.SetShares(shares)
return shareNameMap(shares), nil
}
func shareNameMap(sharesByName map[string]*tailfs.Share) map[string]string {
sharesMap := make(map[string]string, len(sharesByName))
for _, share := range sharesByName {
sharesMap[share.Name] = share.Path
}
return sharesMap
}
// tailfsNotifyShares notifies IPN bus listeners (e.g. Mac Application process)
// about the latest set of shares, supplied as a map of name -> directory.
func (b *LocalBackend) tailfsNotifyShares(shares map[string]string) {
b.send(ipn.Notify{TailfsShares: shares})
}
// tailfsNotifyCurrentSharesLocked sends an ipn.Notify with the current set of
// tailfs shares.
func (b *LocalBackend) tailfsNotifyCurrentSharesLocked() {
shares, err := b.tailfsGetSharesLocked()
if err != nil {
b.logf("error notifying current tailfs shares: %v", err)
return
}
// Do the below on a goroutine to avoid deadlocking on b.mu in b.send().
go b.tailfsNotifyShares(shareNameMap(shares))
}
// TailfsGetShares() returns the current set of shares from the state store.
func (b *LocalBackend) TailfsGetShares() (map[string]*tailfs.Share, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.tailfsGetSharesLocked()
}
func (b *LocalBackend) tailfsGetSharesLocked() (map[string]*tailfs.Share, error) {
data, err := b.store.ReadState(tailfsSharesStateKey)
if err != nil {
if errors.Is(err, ipn.ErrStateNotExist) {
return make(map[string]*tailfs.Share), nil
}
return nil, fmt.Errorf("read state: %w", err)
}
var shares map[string]*tailfs.Share
err = json.Unmarshal(data, &shares)
if err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
return shares, nil
}
// updateTailfsListenersLocked creates listeners on the local Tailfs port.
// This is needed to properly route local traffic when using kernel networking
// mode.
func (b *LocalBackend) updateTailfsListenersLocked() {
if b.netMap == nil {
return
}
addrs := b.netMap.GetAddresses()
oldListeners := b.tailfsListeners
newListeners := make(map[netip.AddrPort]*localListener, addrs.Len())
for i := range addrs.LenIter() {
if fs, ok := b.sys.TailfsForLocal.GetOK(); ok {
addrPort := netip.AddrPortFrom(addrs.At(i).Addr(), TailfsLocalPort)
if sl, ok := b.tailfsListeners[addrPort]; ok {
newListeners[addrPort] = sl
delete(oldListeners, addrPort)
continue // already listening
}
sl := b.newTailfsListener(context.Background(), fs, addrPort, b.logf)
newListeners[addrPort] = sl
go sl.Run()
}
}
// At this point, anything left in oldListeners can be stopped.
for _, sl := range oldListeners {
sl.cancel()
}
}
// newTailfsListener returns a listener for local connections to a tailfs
// WebDAV FileSystem.
func (b *LocalBackend) newTailfsListener(ctx context.Context, fs *tailfs.FileSystemForLocal, ap netip.AddrPort, logf logger.Logf) *localListener {
ctx, cancel := context.WithCancel(ctx)
return &localListener{
b: b,
ap: ap,
ctx: ctx,
cancel: cancel,
logf: logf,
handler: func(conn net.Conn) error {
return fs.HandleConn(conn, conn.RemoteAddr())
},
bo: backoff.NewBackoff(fmt.Sprintf("tailfs-listener-%d", ap.Port()), logf, 30*time.Second),
}
}
// updateTailfsPeersLocked sets all applicable peers from the netmap as tailfs
// remotes.
func (b *LocalBackend) updateTailfsPeersLocked(nm *netmap.NetworkMap) {
fs, ok := b.sys.TailfsForLocal.GetOK()
if !ok {
return
}
tailfsRemotes := make([]*tailfs.Remote, 0, len(nm.Peers))
for _, p := range nm.Peers {
peerID := p.ID()
url := fmt.Sprintf("%s/%s", peerAPIBase(nm, p), tailfsPrefix[1:])
tailfsRemotes = append(tailfsRemotes, &tailfs.Remote{
Name: p.DisplayName(false),
URL: url,
Available: func() bool {
// TODO(oxtoacart): need to figure out a performant and reliable way to only
// show the peers that have shares to which we have access
// This will require work on the control server to transmit the inverse
// of the "tailscale.com/cap/tailfs" capability.
// For now, at least limit it only to nodes that are online.
// Note, we have to iterate the latest netmap because the peer we got from the first iteration may not be it
b.mu.Lock()
latestNetMap := b.netMap
b.mu.Unlock()
for _, candidate := range latestNetMap.Peers {
if candidate.ID() == peerID {
online := candidate.Online()
// TODO(oxtoacart): for some reason, this correctly
// catches when a node goes from offline to online,
// but not the other way around...
return online != nil && *online
}
}
// peer not found, must not be available
return false
},
})
}
fs.SetRemotes(b.netMap.Domain, tailfsRemotes, &tailfsTransport{b: b})
}

@ -0,0 +1,40 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"fmt"
"testing"
)
func TestNormalizeShareName(t *testing.T) {
tests := []struct {
name string
want string
err error
}{
{
name: " (_this is A 5 nAme )_ ",
want: "(_this is a 5 name )_",
},
{
name: "",
err: errInvalidShareName,
},
{
name: "generally good except for .",
err: errInvalidShareName,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("name %q", tt.name), func(t *testing.T) {
got, err := normalizeShareName(tt.name)
if tt.err != nil && err != tt.err {
t.Errorf("wanted error %v, got %v", tt.err, err)
} else if got != tt.want {
t.Errorf("wanted %q, got %q", tt.want, got)
}
})
}
}

@ -18,7 +18,9 @@ import (
"net/http/httputil" "net/http/httputil"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"os/exec" "os/exec"
"path"
"runtime" "runtime"
"slices" "slices"
"strconv" "strconv"
@ -41,6 +43,7 @@ import (
"tailscale.com/net/portmapper" "tailscale.com/net/portmapper"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/taildrop" "tailscale.com/taildrop"
"tailscale.com/tailfs"
"tailscale.com/tka" "tailscale.com/tka"
"tailscale.com/tstime" "tailscale.com/tstime"
"tailscale.com/types/key" "tailscale.com/types/key"
@ -107,6 +110,8 @@ var handler = map[string]localAPIHandler{
"serve-config": (*Handler).serveServeConfig, "serve-config": (*Handler).serveServeConfig,
"set-dns": (*Handler).serveSetDNS, "set-dns": (*Handler).serveSetDNS,
"set-expiry-sooner": (*Handler).serveSetExpirySooner, "set-expiry-sooner": (*Handler).serveSetExpirySooner,
"tailfs/fileserver-address": (*Handler).serveTailfsFileServerAddr,
"tailfs/shares": (*Handler).serveShares,
"start": (*Handler).serveStart, "start": (*Handler).serveStart,
"status": (*Handler).serveStatus, "status": (*Handler).serveStatus,
"tka/init": (*Handler).serveTKAInit, "tka/init": (*Handler).serveTKAInit,
@ -1107,7 +1112,7 @@ func (h *Handler) connIsLocalAdmin() bool {
if err != nil { if err != nil {
return false return false
} }
// Short timeout just in case sudo hands for some reason. // Short timeout just in case sudo hangs for some reason.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := exec.CommandContext(ctx, "sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil { if err := exec.CommandContext(ctx, "sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil {
@ -1120,6 +1125,34 @@ func (h *Handler) connIsLocalAdmin() bool {
} }
} }
func (h *Handler) getUsername() (string, error) {
if h.ConnIdentity == nil {
h.logf("[unexpected] missing ConnIdentity in LocalAPI Handler")
return "", errors.New("missing ConnIdentity")
}
switch runtime.GOOS {
case "windows":
tok, err := h.ConnIdentity.WindowsToken()
if err != nil {
return "", fmt.Errorf("get windows token: %w", err)
}
defer tok.Close()
return tok.Username()
case "darwin", "linux":
uid, ok := h.ConnIdentity.Creds().UserID()
if !ok {
return "", errors.New("missing user ID")
}
u, err := osuser.LookupByUID(uid)
if err != nil {
return "", fmt.Errorf("lookup user: %w", err)
}
return u.Username, nil
default:
return "", errors.New("unsupported OS")
}
}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) { func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead { if !h.PermitRead {
http.Error(w, "IP forwarding check access denied", http.StatusForbidden) http.Error(w, "IP forwarding check access denied", http.StatusForbidden)
@ -2498,6 +2531,95 @@ func (h *Handler) serveUpdateProgress(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ups) json.NewEncoder(w).Encode(ups)
} }
// serveTailfsFileServerAddr handles updates of the tailfs file server address.
func (h *Handler) serveTailfsFileServerAddr(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
http.Error(w, "only PUT allowed", http.StatusMethodNotAllowed)
return
}
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.b.TailfsSetFileServerAddr(string(b))
w.WriteHeader(http.StatusCreated)
}
// serveShares handles the management of tailfs shares.
func (h *Handler) serveShares(w http.ResponseWriter, r *http.Request) {
if !h.b.TailfsSharingEnabled() {
http.Error(w, `tailfs sharing not enabled, please add the attribute "tailfs:share" to this node in your ACLs' "nodeAttrs" section`, http.StatusInternalServerError)
return
}
switch r.Method {
case "PUT":
var share tailfs.Share
err := json.NewDecoder(r.Body).Decode(&share)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
share.Path = path.Clean(share.Path)
fi, err := os.Stat(share.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !fi.IsDir() {
http.Error(w, "not a directory", http.StatusBadRequest)
return
}
if tailfs.AllowShareAs() {
// share as the connected user
username, err := h.getUsername()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
share.As = username
}
err = h.b.TailfsAddShare(&share)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
case "DELETE":
var share tailfs.Share
err := json.NewDecoder(r.Body).Decode(&share)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = h.b.TailfsRemoveShare(share.Name)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "share not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
case "GET":
shares, err := h.b.TailfsGetShares()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(shares)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
default:
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
}
}
var ( var (
metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests") metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests")

@ -16,4 +16,4 @@
) { ) {
src = ./.; src = ./.;
}).shellNix }).shellNix
# nix-direnv cache busting line: sha256-OfwliH0EmrGoOVog8b27F0p5+foElOrRZkOtGR4+Sts= # nix-direnv cache busting line: sha256-eci4f6golU1eIQOezplA+I+gmOfof40ktIdpr0v/uMc=

@ -1345,6 +1345,8 @@ const (
// PeerCapabilityWebUI grants the ability for a peer to edit features from the // PeerCapabilityWebUI grants the ability for a peer to edit features from the
// device Web UI. // device Web UI.
PeerCapabilityWebUI PeerCapability = "tailscale.com/cap/webui" PeerCapabilityWebUI PeerCapability = "tailscale.com/cap/webui"
// PeerCapabilityTailfs grants the ability for a peer to access tailfs shares.
PeerCapabilityTailfs PeerCapability = "tailscale.com/cap/tailfs"
) )
// NodeCapMap is a map of capabilities to their optional values. It is valid for // NodeCapMap is a map of capabilities to their optional values. It is valid for
@ -2087,7 +2089,7 @@ const (
CapabilitySSHRuleIn NodeCapability = "https://tailscale.com/cap/ssh-rule-in" // some SSH rule reach this node CapabilitySSHRuleIn NodeCapability = "https://tailscale.com/cap/ssh-rule-in" // some SSH rule reach this node
CapabilityDataPlaneAuditLogs NodeCapability = "https://tailscale.com/cap/data-plane-audit-logs" // feature enabled CapabilityDataPlaneAuditLogs NodeCapability = "https://tailscale.com/cap/data-plane-audit-logs" // feature enabled
CapabilityDebug NodeCapability = "https://tailscale.com/cap/debug" // exposes debug endpoints over the PeerAPI CapabilityDebug NodeCapability = "https://tailscale.com/cap/debug" // exposes debug endpoints over the PeerAPI
CapabilityHTTPS NodeCapability = "https" // https cert provisioning enabled on tailnet CapabilityHTTPS NodeCapability = "https"
// CapabilityBindToInterfaceByRoute changes how Darwin nodes create // CapabilityBindToInterfaceByRoute changes how Darwin nodes create
// sockets (in the net/netns package). See that package for more // sockets (in the net/netns package). See that package for more
@ -2208,6 +2210,9 @@ const (
// NodeAttrProbeUDPLifetime makes the client probe UDP path lifetime at the // NodeAttrProbeUDPLifetime makes the client probe UDP path lifetime at the
// tail end of an active direct connection in magicsock. // tail end of an active direct connection in magicsock.
NodeAttrProbeUDPLifetime NodeCapability = "probe-udp-lifetime" NodeAttrProbeUDPLifetime NodeCapability = "probe-udp-lifetime"
// NodeAttrsTailfsSharingEnabled enables sharing via Tailfs.
NodeAttrsTailfsSharingEnabled NodeCapability = "tailfs:share"
) )
// SetDNSRequest is a request to add a DNS record. // SetDNSRequest is a request to add a DNS record.

@ -0,0 +1,83 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"context"
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/tailscale/xnet/webdav"
)
// birthTimingFS extends a webdav.FileSystem to return FileInfos that implement
// the webdav.BirthTimer interface.
type birthTimingFS struct {
webdav.FileSystem
}
func (fs *birthTimingFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
fi, err := fs.FileSystem.Stat(ctx, name)
if err != nil {
return nil, err
}
return &birthTimingFileInfo{fi}, nil
}
func (fs *birthTimingFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
f, err := fs.FileSystem.OpenFile(ctx, name, flag, perm)
if err != nil {
return nil, err
}
return &birthTimingFile{f}, nil
}
// birthTimingFileInfo extends an os.FileInfo to implement the BirthTimer
// interface.
type birthTimingFileInfo struct {
os.FileInfo
}
func (fi *birthTimingFileInfo) BirthTime(ctx context.Context) (time.Time, error) {
if fi.Sys() == nil {
return time.Time{}, webdav.ErrNotImplemented
}
if !times.HasBirthTime {
return time.Time{}, webdav.ErrNotImplemented
}
return times.Get(fi.FileInfo).BirthTime(), nil
}
// birthTimingFile extends a webdav.File to return FileInfos that implement the
// BirthTimer interface.
type birthTimingFile struct {
webdav.File
}
func (f *birthTimingFile) Stat() (fs.FileInfo, error) {
fi, err := f.File.Stat()
if err != nil {
return nil, err
}
return &birthTimingFileInfo{fi}, nil
}
func (f *birthTimingFile) Readdir(count int) ([]fs.FileInfo, error) {
fis, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
for i, fi := range fis {
fis[i] = &birthTimingFileInfo{fi}
}
return fis, nil
}

@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// BirthTime is not supported on Linux, so only run the test on windows and Mac.
//go:build windows || darwin
package tailfs
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/tailscale/xnet/webdav"
)
func TestBirthTiming(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
fs := &birthTimingFS{webdav.Dir(dir)}
// create a file
filename := "thefile"
fullPath := filepath.Join(dir, filename)
err := os.WriteFile(fullPath, []byte("hello beautiful world"), 0644)
if err != nil {
t.Fatalf("writing file failed: %s", err)
}
// wait a little bit
time.Sleep(1 * time.Second)
// append to the file to change its mtime
file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
t.Fatalf("opening file failed: %s", err)
}
_, err = file.Write([]byte("lookin' good!"))
if err != nil {
t.Fatalf("appending to file failed: %s", err)
}
err = file.Close()
if err != nil {
t.Fatalf("closing file failed: %s", err)
}
checkFileInfo := func(fi os.FileInfo) {
if fi.ModTime().IsZero() {
t.Fatal("FileInfo should have a non-zero ModTime")
}
bt, ok := fi.(webdav.BirthTimer)
if !ok {
t.Fatal("FileInfo should be a BirthTimer")
}
birthTime, err := bt.BirthTime(ctx)
if err != nil {
t.Fatalf("BirthTime() failed: %s", err)
}
if birthTime.IsZero() {
t.Fatal("BirthTime() should return a non-zero time")
}
if !fi.ModTime().After(birthTime) {
t.Fatal("ModTime() should be after BirthTime()")
}
}
fi, err := fs.Stat(ctx, filename)
if err != nil {
t.Fatalf("statting file failed: %s", err)
}
checkFileInfo(fi)
wfile, err := fs.OpenFile(ctx, filename, os.O_RDONLY, 0)
if err != nil {
t.Fatalf("opening file failed: %s", err)
}
defer wfile.Close()
fi, err = wfile.Stat()
if err != nil {
t.Fatalf("statting file failed: %s", err)
}
if fi == nil {
t.Fatal("statting file returned nil FileInfo")
}
checkFileInfo(fi)
dfile, err := fs.OpenFile(ctx, ".", os.O_RDONLY, 0)
if err != nil {
t.Fatalf("opening directory failed: %s", err)
}
defer dfile.Close()
fis, err := dfile.Readdir(0)
if err != nil {
t.Fatalf("readdir failed: %s", err)
}
if len(fis) != 1 {
t.Fatalf("readdir should have returned 1 file info, but returned %d", 1)
}
checkFileInfo(fis[0])
}

@ -0,0 +1,227 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package compositefs provides a webdav.FileSystem that is composi
package compositefs
import (
"io"
"log"
"os"
"path"
"slices"
"strings"
"sync"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
// Child is a child filesystem of a CompositeFileSystem
type Child struct {
// Name is the name of the child
Name string
// FS is the child's FileSystem
FS webdav.FileSystem
// Available is a function indicating whether or not the child is currently
// available.
Available func() bool
}
func (c *Child) isAvailable() bool {
if c.Available == nil {
return true
}
return c.Available()
}
// Options specifies options for configuring a CompositeFileSystem.
type Options struct {
// Logf specifies a logging function to use
Logf logger.Logf
// StatChildren, if true, causes the CompositeFileSystem to stat its child
// folders when generating a root directory listing. This gives more
// accurate information but increases latency.
StatChildren bool
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
}
// New constructs a CompositeFileSystem that logs using the given logf.
func New(opts Options) *CompositeFileSystem {
logf := opts.Logf
if logf == nil {
logf = log.Printf
}
fs := &CompositeFileSystem{
logf: logf,
statChildren: opts.StatChildren,
}
if opts.Clock != nil {
fs.now = opts.Clock.Now
} else {
fs.now = time.Now
}
return fs
}
// CompositeFileSystem is a webdav.FileSystem that is composed of multiple
// child webdav.FileSystems. Each child is identified by a name and appears
// as a folder within the root of the CompositeFileSystem, with the children
// sorted lexicographically by name.
//
// Children in a CompositeFileSystem can only be added or removed via calls to
// the AddChild and RemoveChild methods, they cannot be added via operations
// on the webdav.FileSystem interface like filesystem.Mkdir or filesystem.OpenFile.
// In other words, the root of the CompositeFileSystem acts as read-only, not
// permitting the addition, removal or renaming of folders.
//
// Rename is only supported within a single child. Renaming across children
// is not supported, as it wouldn't be possible to perform it atomically.
type CompositeFileSystem struct {
logf logger.Logf
statChildren bool
now func() time.Time
// childrenMu guards children
childrenMu sync.Mutex
children []*Child
}
// AddChild ads a single child with the given name, replacing any existing
// child with the same name.
func (cfs *CompositeFileSystem) AddChild(child *Child) {
cfs.childrenMu.Lock()
oldIdx, oldChild := cfs.findChildLocked(child.Name)
if oldChild != nil {
// replace old child
cfs.children[oldIdx] = child
} else {
// insert new child
cfs.children = slices.Insert(cfs.children, oldIdx, child)
}
cfs.childrenMu.Unlock()
if oldChild != nil {
if c, ok := oldChild.FS.(io.Closer); ok {
if err := c.Close(); err != nil {
cfs.logf("closing child filesystem %v: %v", child.Name, err)
}
}
}
}
// RemoveChild removes the child with the given name, if it exists.
func (cfs *CompositeFileSystem) RemoveChild(name string) {
cfs.childrenMu.Lock()
oldPos, oldChild := cfs.findChildLocked(name)
if oldChild != nil {
// remove old child
copy(cfs.children[oldPos:], cfs.children[oldPos+1:])
cfs.children = cfs.children[:len(cfs.children)-1]
}
cfs.childrenMu.Unlock()
if oldChild != nil {
closer, ok := oldChild.FS.(io.Closer)
if ok {
err := closer.Close()
if err != nil {
cfs.logf("failed to close child filesystem %v: %v", name, err)
}
}
}
}
// SetChildren replaces the entire existing set of children with the given
// ones.
func (cfs *CompositeFileSystem) SetChildren(children ...*Child) {
slices.SortFunc(children, func(a, b *Child) int {
return strings.Compare(a.Name, b.Name)
})
cfs.childrenMu.Lock()
oldChildren := cfs.children
cfs.children = children
cfs.childrenMu.Unlock()
for _, child := range oldChildren {
closer, ok := child.FS.(io.Closer)
if ok {
_ = closer.Close()
}
}
}
// GetChild returns the child with the given name and a boolean indicating
// whether or not it was found.
func (cfs *CompositeFileSystem) GetChild(name string) (webdav.FileSystem, bool) {
_, child := cfs.findChildLocked(name)
if child == nil {
return nil, false
}
return child.FS, true
}
func (cfs *CompositeFileSystem) findChildLocked(name string) (int, *Child) {
var child *Child
i, found := slices.BinarySearchFunc(cfs.children, name, func(child *Child, name string) int {
return strings.Compare(child.Name, name)
})
if found {
child = cfs.children[i]
}
return i, child
}
// pathInfoFor returns a pathInfo for the given filename. If the filename
// refers to a Child that does not exist within this CompositeFileSystem,
// it will return the error os.ErrNotExist. Even when returning an error,
// it will still return a complete pathInfo.
func (cfs *CompositeFileSystem) pathInfoFor(name string) (pathInfo, error) {
cfs.childrenMu.Lock()
defer cfs.childrenMu.Unlock()
var info pathInfo
pathComponents := shared.CleanAndSplit(name)
_, info.child = cfs.findChildLocked(pathComponents[0])
info.refersToChild = len(pathComponents) == 1
if !info.refersToChild {
info.pathOnChild = path.Join(pathComponents[1:]...)
}
if info.child == nil {
return info, os.ErrNotExist
}
return info, nil
}
// pathInfo provides information about a path
type pathInfo struct {
// child is the Child corresponding to the first component of the path.
child *Child
// refersToChild indicates that that path refers directly to the child
// (i.e. the path has only 1 component).
refersToChild bool
// pathOnChild is the path within the child (i.e. path minus leading component)
// if and only if refersToChild is false.
pathOnChild string
}
func (cfs *CompositeFileSystem) Close() error {
cfs.childrenMu.Lock()
children := cfs.children
cfs.childrenMu.Unlock()
for _, child := range children {
closer, ok := child.FS.(io.Closer)
if ok {
_ = closer.Close()
}
}
return nil
}

@ -0,0 +1,497 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"errors"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
"tailscale.com/tstest"
)
func TestStat(t *testing.T) {
cfs, dir1, _, clock, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
name string
expected fs.FileInfo
err error
}{
{
label: "root folder",
name: "/",
expected: &shared.StaticFileInfo{
Named: "/",
Sized: 0,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote1",
name: "/remote1",
expected: &shared.StaticFileInfo{
Named: "/remote1",
Sized: 0,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "remote2",
name: "/remote2",
expected: &shared.StaticFileInfo{
Named: "/remote2",
Sized: 0,
ModdedTime: clock.Now(),
Dir: true,
},
},
{
label: "non-existent remote",
name: "/remote3",
err: os.ErrNotExist,
},
{
label: "file on remote1",
name: "/remote1/file1.txt",
expected: &shared.StaticFileInfo{
Named: "/remote1/file1.txt",
Sized: stat(t, filepath.Join(dir1, "file1.txt")).Size(),
ModdedTime: stat(t, filepath.Join(dir1, "file1.txt")).ModTime(),
Dir: false,
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
fi, err := cfs.Stat(ctx, test.name)
if test.err != nil {
if err == nil || !errors.Is(err, test.err) {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
}
})
}
}
func TestStatWithStatChildren(t *testing.T) {
cfs, dir1, dir2, _, close := createFileSystem(t, &Options{StatChildren: true})
defer close()
tests := []struct {
label string
name string
expected fs.FileInfo
}{
{
label: "root folder",
name: "/",
expected: &shared.StaticFileInfo{
Named: "/",
Sized: 0,
ModdedTime: stat(t, dir2).ModTime(), // ModTime should be greatest modtime of children
Dir: true,
},
},
{
label: "remote1",
name: "/remote1",
expected: &shared.StaticFileInfo{
Named: "/remote1",
Sized: stat(t, dir1).Size(),
ModdedTime: stat(t, dir1).ModTime(),
Dir: true,
},
},
{
label: "remote2",
name: "/remote2",
expected: &shared.StaticFileInfo{
Named: "/remote2",
Sized: stat(t, dir2).Size(),
ModdedTime: stat(t, dir2).ModTime(),
Dir: true,
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
fi, err := cfs.Stat(ctx, test.name)
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
infosEqual(t, test.expected, fi)
}
})
}
}
func TestMkdir(t *testing.T) {
fs, _, _, _, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
name string
perm os.FileMode
err error
}{
{
label: "attempt to create root folder",
name: "/",
},
{
label: "attempt to create remote",
name: "/remote1",
},
{
label: "attempt to create non-existent remote",
name: "/remote3",
err: os.ErrPermission,
},
{
label: "attempt to create file on non-existent remote",
name: "/remote3/somefile.txt",
err: os.ErrNotExist,
},
{
label: "success",
name: "/remote1/newfile.txt",
perm: 0772,
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Mkdir(ctx, test.name, test.perm)
if test.err != nil {
if err == nil || !errors.Is(err, test.err) {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
fi, err := fs.Stat(ctx, test.name)
if err != nil {
t.Errorf("unable to stat file: %v", err)
} else {
if fi.Name() != test.name {
t.Errorf("expected name: %v got: %v", test.name, fi.Name())
}
if !fi.IsDir() {
t.Error("expected directory")
}
}
}
}
})
}
}
func TestRemoveAll(t *testing.T) {
fs, _, _, _, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
name string
err error
}{
{
label: "attempt to remove root folder",
name: "/",
err: os.ErrPermission,
},
{
label: "attempt to remove remote",
name: "/remote1",
err: os.ErrPermission,
},
{
label: "attempt to remove non-existent remote",
name: "/remote3",
err: os.ErrPermission,
},
{
label: "attempt to remove file on non-existent remote",
name: "/remote3/somefile.txt",
err: os.ErrNotExist,
},
{
label: "remove non-existent file",
name: "/remote1/nonexistent.txt",
},
{
label: "remove existing file",
name: "/remote1/dir1",
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.RemoveAll(ctx, test.name)
if test.err != nil {
if err == nil || !errors.Is(err, test.err) {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
_, err := fs.Stat(ctx, test.name)
if !os.IsNotExist(err) {
t.Errorf("expected dir to be gone: %v", err)
}
}
}
})
}
}
func TestRename(t *testing.T) {
fs, _, _, _, close := createFileSystem(t, nil)
defer close()
tests := []struct {
label string
oldName string
newName string
err error
expectedNewInfo *shared.StaticFileInfo
}{
{
label: "attempt to move root folder",
oldName: "/",
newName: "/remote2/copy.txt",
err: os.ErrPermission,
},
{
label: "attempt to move to root folder",
oldName: "/remote1/file1.txt",
newName: "/",
err: os.ErrPermission,
},
{
label: "attempt to move to remote",
oldName: "/remote1/file1.txt",
newName: "/remote2",
err: os.ErrPermission,
},
{
label: "attempt to move to non-existent remote",
oldName: "/remote1/file1.txt",
newName: "/remote3",
err: os.ErrPermission,
},
{
label: "attempt to move file from non-existent remote",
oldName: "/remote3/file1.txt",
newName: "/remote1/file1.txt",
err: os.ErrNotExist,
},
{
label: "attempt to move file to a non-existent remote",
oldName: "/remote2/file2.txt",
newName: "/remote3/file2.txt",
err: os.ErrNotExist,
},
{
label: "attempt to move file across remotes",
oldName: "/remote1/file1.txt",
newName: "/remote2/file1.txt",
err: os.ErrPermission,
},
{
label: "attempt to move remote itself",
oldName: "/remote1",
newName: "/remote2",
err: os.ErrPermission,
},
{
label: "attempt to move to a remote",
oldName: "/remote1/file2.txt",
newName: "/remote2",
err: os.ErrPermission,
},
{
label: "move file within remote",
oldName: "/remote2/file2.txt",
newName: "/remote2/file3.txt",
expectedNewInfo: &shared.StaticFileInfo{
Named: "/remote2/file3.txt",
Sized: 5,
Dir: false,
},
},
}
ctx := context.Background()
for _, test := range tests {
t.Run(test.label, func(t *testing.T) {
err := fs.Rename(ctx, test.oldName, test.newName)
if test.err != nil {
if err == nil || test.err.Error() != err.Error() {
t.Errorf("expected error: %v got: %v", test.err, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
fi, err := fs.Stat(ctx, test.newName)
if err != nil {
t.Errorf("unexpected error: %v", err)
} else {
// Override modTime to avoid having to compare it
test.expectedNewInfo.ModdedTime = fi.ModTime()
infosEqual(t, test.expectedNewInfo, fi)
}
}
}
})
}
}
func createFileSystem(t *testing.T, opts *Options) (webdav.FileSystem, string, string, *tstest.Clock, func()) {
l1, dir1 := startRemote(t)
l2, dir2 := startRemote(t)
// Make some files, use perms 0666 as lowest common denominator that works
// on both UNIX and Windows.
err := os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("12345"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("54321"), 0666)
if err != nil {
t.Fatal(err)
}
// make some directories
err = os.Mkdir(filepath.Join(dir1, "dir1"), 0666)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(filepath.Join(dir2, "dir2"), 0666)
if err != nil {
t.Fatal(err)
}
if opts == nil {
opts = &Options{}
}
if opts.Logf == nil {
opts.Logf = t.Logf
}
clock := tstest.NewClock(tstest.ClockOpts{Start: time.Now()})
opts.Clock = clock
fs := New(*opts)
fs.AddChild(&Child{Name: "remote4", FS: &closeableFS{webdav.Dir(dir2)}})
fs.SetChildren(&Child{Name: "remote2", FS: webdav.Dir(dir2)},
&Child{Name: "remote3", FS: &closeableFS{webdav.Dir(dir2)}},
)
fs.AddChild(&Child{Name: "remote1", FS: webdav.Dir(dir1)})
fs.RemoveChild("remote3")
child, ok := fs.GetChild("remote1")
if !ok || child == nil {
t.Fatal("unable to GetChild(remote1)")
}
child, ok = fs.GetChild("remote2")
if !ok || child == nil {
t.Fatal("unable to GetChild(remote2)")
}
child, ok = fs.GetChild("remote3")
if ok || child != nil {
t.Fatal("should have been able to GetChild(remote3)")
}
child, ok = fs.GetChild("remote4")
if ok || child != nil {
t.Fatal("should have been able to GetChild(remote4)")
}
return fs, dir1, dir2, clock, func() {
defer l1.Close()
defer os.RemoveAll(dir1)
defer l2.Close()
defer os.RemoveAll(dir2)
}
}
func stat(t *testing.T, path string) fs.FileInfo {
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return fi
}
func startRemote(t *testing.T) (net.Listener, string) {
dir := t.TempDir()
l, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
h := &webdav.Handler{
FileSystem: webdav.Dir(dir),
LockSystem: webdav.NewMemLS(),
}
s := &http.Server{Handler: h}
go s.Serve(l)
return l, dir
}
func infosEqual(t *testing.T, expected, actual fs.FileInfo) {
t.Helper()
if expected.Name() != actual.Name() {
t.Errorf("expected name: %v got: %v", expected.Name(), actual.Name())
}
if expected.Size() != actual.Size() {
t.Errorf("expected Size: %v got: %v", expected.Size(), actual.Size())
}
if !expected.ModTime().Truncate(time.Second).UTC().Equal(actual.ModTime().Truncate(time.Second).UTC()) {
t.Errorf("expected ModTime: %v got: %v", expected.ModTime(), actual.ModTime())
}
if expected.IsDir() != actual.IsDir() {
t.Errorf("expected IsDir: %v got: %v", expected.IsDir(), actual.IsDir())
}
}
// closeableFS is a webdav.FileSystem that implements io.Closer()
type closeableFS struct {
webdav.FileSystem
}
func (cfs *closeableFS) Close() error {
return nil
}

@ -0,0 +1,39 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"os"
"tailscale.com/tailfs/shared"
)
// Mkdir implements webdav.Filesystem. The root of this file system is
// read-only, so any attempts to make directories within the root will fail
// with os.ErrPermission. Attempts to make directories within one of the child
// filesystems will be handled by the respective child.
func (cfs *CompositeFileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
if shared.IsRoot(name) {
// root directory already exists, consider this okay
return nil
}
pathInfo, err := cfs.pathInfoFor(name)
if pathInfo.refersToChild {
// children can't be made
if pathInfo.child != nil {
// since child already exists, consider this okay
return nil
}
// since child doesn't exist, return permission error
return os.ErrPermission
}
if err != nil {
return err
}
return pathInfo.child.FS.Mkdir(ctx, pathInfo.pathOnChild, perm)
}

@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"io/fs"
"os"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
)
// OpenFile implements interface webdav.Filesystem.
func (cfs *CompositeFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if !shared.IsRoot(name) {
pathInfo, err := cfs.pathInfoFor(name)
if err != nil {
return nil, err
}
if pathInfo.refersToChild {
// this is the child itself, ask it to open its root
return pathInfo.child.FS.OpenFile(ctx, "/", flag, perm)
}
return pathInfo.child.FS.OpenFile(ctx, pathInfo.pathOnChild, flag, perm)
}
// the root directory contains one directory for each child
di, err := cfs.Stat(ctx, name)
if err != nil {
return nil, err
}
return &shared.DirFile{
Info: di,
LoadChildren: func() ([]fs.FileInfo, error) {
cfs.childrenMu.Lock()
children := cfs.children
cfs.childrenMu.Unlock()
childInfos := make([]fs.FileInfo, 0, len(cfs.children))
for _, c := range children {
if c.isAvailable() {
var childInfo fs.FileInfo
if cfs.statChildren {
fi, err := c.FS.Stat(ctx, "/")
if err != nil {
return nil, err
}
// we use the full name
childInfo = shared.RenamedFileInfo(ctx, c.Name, fi)
} else {
// always use now() as the modified time to bust caches
childInfo = shared.ReadOnlyDirInfo(c.Name, cfs.now())
}
childInfos = append(childInfos, childInfo)
}
}
return childInfos, nil
},
}, nil
}

@ -0,0 +1,33 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"os"
"tailscale.com/tailfs/shared"
)
// RemoveAll implements webdav.File. The root of this file system is read-only,
// so attempting to call RemoveAll on the root will fail with os.ErrPermission.
// RemoveAll within a child will be handled by the respective child.
func (cfs *CompositeFileSystem) RemoveAll(ctx context.Context, name string) error {
if shared.IsRoot(name) {
// root directory is read-only
return os.ErrPermission
}
pathInfo, err := cfs.pathInfoFor(name)
if pathInfo.refersToChild {
// children can't be removed
return os.ErrPermission
}
if err != nil {
return err
}
return pathInfo.child.FS.RemoveAll(ctx, pathInfo.pathOnChild)
}

@ -0,0 +1,49 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"os"
"tailscale.com/tailfs/shared"
)
// Rename implements interface webdav.FileSystem. The root of this file system
// is read-only, so any attempt to rename a child within the root of this
// filesystem will fail with os.ErrPermission. Renaming across children is not
// supported and will fail with os.ErrPermission. Renaming within a child will
// be handled by the respective child.
func (cfs *CompositeFileSystem) Rename(ctx context.Context, oldName, newName string) error {
if shared.IsRoot(oldName) || shared.IsRoot(newName) {
// root directory is read-only
return os.ErrPermission
}
oldPathInfo, err := cfs.pathInfoFor(oldName)
if oldPathInfo.refersToChild {
// children themselves are read-only
return os.ErrPermission
}
if err != nil {
return err
}
newPathInfo, err := cfs.pathInfoFor(newName)
if newPathInfo.refersToChild {
// children themselves are read-only
return os.ErrPermission
}
if err != nil {
return err
}
if oldPathInfo.child != newPathInfo.child {
// moving a file across children is not permitted
return os.ErrPermission
}
// file is moving within the same child, let the child handle it
return oldPathInfo.child.FS.Rename(ctx, oldPathInfo.pathOnChild, newPathInfo.pathOnChild)
}

@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package compositefs
import (
"context"
"io/fs"
"tailscale.com/tailfs/shared"
)
// Stat implements webdav.FileSystem.
func (cfs *CompositeFileSystem) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
if shared.IsRoot(name) {
// Root is a directory
// always use now() as the modified time to bust caches
fi := shared.ReadOnlyDirInfo(name, cfs.now())
if cfs.statChildren {
// update last modified time based on children
cfs.childrenMu.Lock()
children := cfs.children
cfs.childrenMu.Unlock()
for i, child := range children {
childInfo, err := child.FS.Stat(ctx, "/")
if err != nil {
return nil, err
}
if i == 0 || childInfo.ModTime().After(fi.ModTime()) {
fi.ModdedTime = childInfo.ModTime()
}
}
}
return fi, nil
}
pathInfo, err := cfs.pathInfoFor(name)
if err != nil {
return nil, err
}
if pathInfo.refersToChild && !cfs.statChildren {
// Return a read-only FileInfo for this child.
// Always use now() as the modified time to bust caches.
return shared.ReadOnlyDirInfo(name, cfs.now()), nil
}
fi, err := pathInfo.child.FS.Stat(ctx, pathInfo.pathOnChild)
if err != nil {
return nil, err
}
// we use the full name, which is different than what the child sees
return shared.RenamedFileInfo(ctx, name, fi), nil
}

@ -0,0 +1,79 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"log"
"net"
"sync"
"syscall"
)
type connListener struct {
ch chan net.Conn
closedCh chan any
closeMu sync.Mutex
}
// newConnListener creates a net.Listener to which one can hand connections
// directly.
func newConnListener() *connListener {
return &connListener{
ch: make(chan net.Conn),
closedCh: make(chan any),
}
}
func (l *connListener) Accept() (net.Conn, error) {
select {
case <-l.closedCh:
// TODO(oxtoacart): make this error match what a regular net.Listener does
return nil, syscall.EINVAL
case conn := <-l.ch:
return conn, nil
}
}
// Addr implements net.Listener. This always returns nil. It is assumed that
// this method is currently unused, so it logs a warning if it ever does get
// called.
func (l *connListener) Addr() net.Addr {
log.Println("warning: unexpected call to connListener.Addr()")
return nil
}
func (l *connListener) Close() error {
l.closeMu.Lock()
defer l.closeMu.Unlock()
select {
case <-l.closedCh:
// Already closed.
return syscall.EINVAL
default:
// We don't close l.ch because someone maybe trying to send to that,
// which would cause a panic.
close(l.closedCh)
return nil
}
}
func (l *connListener) HandleConn(c net.Conn, remoteAddr net.Addr) error {
select {
case <-l.closedCh:
return syscall.EINVAL
case l.ch <- &connWithRemoteAddr{Conn: c, remoteAddr: remoteAddr}:
// Connection has been accepted.
}
return nil
}
type connWithRemoteAddr struct {
net.Conn
remoteAddr net.Addr
}
func (c *connWithRemoteAddr) RemoteAddr() net.Addr {
return c.remoteAddr
}

@ -0,0 +1,68 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"log"
"net"
"testing"
)
func TestConnListener(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatalf("failed to Listen: %s", err)
}
cl := newConnListener()
// Test that we can accept a connection
cc, err := net.Dial("tcp", l.Addr().String())
if err != nil {
t.Fatalf("failed to Dial: %s", err)
}
defer cc.Close()
sc, err := l.Accept()
if err != nil {
t.Fatalf("failed to Accept: %s", err)
}
remoteAddr := &net.TCPAddr{IP: net.ParseIP("10.10.10.10"), Port: 1234}
go func() {
err := cl.HandleConn(sc, remoteAddr)
if err != nil {
log.Printf("failed to HandleConn: %s", err)
}
}()
clc, err := cl.Accept()
if err != nil {
t.Fatalf("failed to Accept: %s", err)
}
defer clc.Close()
if clc.RemoteAddr().String() != remoteAddr.String() {
t.Fatalf("ConnListener accepted the wrong connection, got %q, want %q", clc.RemoteAddr(), remoteAddr)
}
err = cl.Close()
if err != nil {
t.Fatalf("failed to Close: %s", err)
}
err = cl.Close()
if err == nil {
t.Fatal("should have failed on second Close")
}
err = cl.HandleConn(sc, remoteAddr)
if err == nil {
t.Fatal("should have failed on HandleConn after Close")
}
_, err = cl.Accept()
if err == nil {
t.Fatal("should have failed on Accept after Close")
}
}

@ -0,0 +1,115 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"net"
"net/http"
"sync"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
)
// FileServer is a standalone WebDAV server that dynamically serves up shares.
// It's typically used in a separate process from the actual Tailfs server to
// serve up files as an unprivileged user.
type FileServer struct {
l net.Listener
shareHandlers map[string]http.Handler
sharesMu sync.RWMutex
}
// NewFileServer constructs a FileServer.
//
// The server attempts to listen at a random address on 127.0.0.1.
// The listen address is available via the Addr() method.
//
// The server has to be told about shares before it can serve them. This is
// accomplished either by calling SetShares(), or locking the shares with
// LockShares(), clearing them with ClearSharesLocked(), adding them
// individually with AddShareLocked(), and finally unlocking them with
// UnlockShares().
//
// The server doesn't actually process requests until the Serve() method is
// called.
func NewFileServer() (*FileServer, error) {
// path := filepath.Join(os.TempDir(), fmt.Sprintf("%v.socket", uuid.New().String()))
// l, err := safesocket.Listen(path)
// if err != nil {
// TODO(oxtoacart): actually get safesocket working in more environments (MacOS Sandboxed, Windows, ???)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
// }
return &FileServer{
l: l,
shareHandlers: make(map[string]http.Handler),
}, nil
}
// Addr returns the address at which this FileServer is listening.
func (s *FileServer) Addr() string {
return s.l.Addr().String()
}
// Serve() starts serving files and blocks until it encounters a fatal error.
func (s *FileServer) Serve() error {
return http.Serve(s.l, s)
}
// LockShares locks the map of shares in preparation for manipulating it.
func (s *FileServer) LockShares() {
s.sharesMu.Lock()
}
// UnlockShares unlocks the map of shares.
func (s *FileServer) UnlockShares() {
s.sharesMu.Unlock()
}
// ClearSharesLocked clears the map of shares, assuming that LockShares() has
// been called first.
func (s *FileServer) ClearSharesLocked() {
s.shareHandlers = make(map[string]http.Handler)
}
// AddShareLocked adds a share to the map of shares, assuming that LockShares()
// has been called first.
func (s *FileServer) AddShareLocked(share, path string) {
s.shareHandlers[share] = &webdav.Handler{
FileSystem: &birthTimingFS{webdav.Dir(path)},
LockSystem: webdav.NewMemLS(),
}
}
// SetShares sets the full map of shares to the new value, mapping name->path.
func (s *FileServer) SetShares(shares map[string]string) {
s.LockShares()
defer s.UnlockShares()
s.ClearSharesLocked()
for name, path := range shares {
s.AddShareLocked(name, path)
}
}
// ServeHTTP implements the http.Handler interface.
func (s *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
parts := shared.CleanAndSplit(r.URL.Path)
r.URL.Path = shared.Join(parts[1:]...)
share := parts[0]
s.sharesMu.RLock()
h, found := s.shareHandlers[share]
s.sharesMu.RUnlock()
if !found {
w.WriteHeader(http.StatusNotFound)
return
}
h.ServeHTTP(w, r)
}
func (s *FileServer) Close() error {
return s.l.Close()
}

@ -0,0 +1,99 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"log"
"net"
"net/http"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/compositefs"
"tailscale.com/tailfs/webdavfs"
"tailscale.com/types/logger"
)
// Remote represents a remote Tailfs node.
type Remote struct {
Name string
URL string
Available func() bool
}
// NewFileSystemForLocal starts serving a filesystem for local clients.
// Inbound connections must be handed to HandleConn.
func NewFileSystemForLocal(logf logger.Logf) *FileSystemForLocal {
if logf == nil {
logf = log.Printf
}
fs := &FileSystemForLocal{
logf: logf,
cfs: compositefs.New(compositefs.Options{Logf: logf}),
listener: newConnListener(),
}
fs.startServing()
return fs
}
// FileSystemForLocal is the Tailfs filesystem exposed to local clients. It
// provides a unified WebDAV interface to remote Tailfs shares on other nodes.
type FileSystemForLocal struct {
logf logger.Logf
cfs *compositefs.CompositeFileSystem
listener *connListener
}
func (s *FileSystemForLocal) startServing() {
hs := &http.Server{
Handler: &webdav.Handler{
FileSystem: s.cfs,
LockSystem: webdav.NewMemLS(),
},
}
go func() {
err := hs.Serve(s.listener)
if err != nil {
// TODO(oxtoacart): should we panic or something different here?
log.Printf("serve: %v", err)
}
}()
}
// HandleConn handles connections from local WebDAV clients
func (s *FileSystemForLocal) HandleConn(conn net.Conn, remoteAddr net.Addr) error {
return s.listener.HandleConn(conn, remoteAddr)
}
// SetRemotes sets the complete set of remotes on the given tailnet domain
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*Remote, transport http.RoundTripper) {
children := make([]*compositefs.Child, 0, len(remotes))
for _, remote := range remotes {
opts := webdavfs.Options{
URL: remote.URL,
Transport: transport,
StatCacheTTL: statCacheTTL,
Logf: s.logf,
}
children = append(children, &compositefs.Child{
Name: remote.Name,
FS: webdavfs.New(opts),
Available: remote.Available,
})
}
domainChild, found := s.cfs.GetChild(domain)
if !found {
domainChild = compositefs.New(compositefs.Options{Logf: s.logf})
s.cfs.SetChildren(&compositefs.Child{Name: domain, FS: domainChild})
}
domainChild.(*compositefs.CompositeFileSystem).SetChildren(children...)
}
// Close() stops serving the WebDAV content
func (s *FileSystemForLocal) Close() error {
s.cfs.Close()
return s.listener.Close()
}

@ -0,0 +1,389 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"bufio"
"encoding/hex"
"fmt"
"log"
"math"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/tailscale/xnet/webdav"
"tailscale.com/safesocket"
"tailscale.com/tailfs/compositefs"
"tailscale.com/tailfs/shared"
"tailscale.com/tailfs/webdavfs"
"tailscale.com/types/logger"
)
var (
disallowShareAs = false
)
// AllowShareAs reports whether sharing files as a specific user is allowed.
func AllowShareAs() bool {
return !disallowShareAs && doAllowShareAs()
}
// Share represents a folder that's shared with remote Tailfs nodes.
type Share struct {
// Name is how this share appears on remote nodes.
Name string `json:"name"`
// Path is the path to the directory on this machine that's being shared.
Path string `json:"path"`
// As is the UNIX or Windows username of the local account used for this
// share. File read/write permissions are enforced based on this username.
As string `json:"who"`
}
func NewFileSystemForRemote(logf logger.Logf) *FileSystemForRemote {
if logf == nil {
logf = log.Printf
}
fs := &FileSystemForRemote{
logf: logf,
lockSystem: webdav.NewMemLS(),
fileSystems: make(map[string]webdav.FileSystem),
userServers: make(map[string]*userServer),
}
return fs
}
// FileSystemForRemote is the Tailfs filesystem exposed to remote nodes. It
// provides a unified WebDAV interface to local directories that have been
// shared.
type FileSystemForRemote struct {
logf logger.Logf
lockSystem webdav.LockSystem
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
fileServerAddr string
shares map[string]*Share
fileSystems map[string]webdav.FileSystem
userServers map[string]*userServer
}
// SetFileServerAddr sets the address of the file server to which we
// should proxy. This is used on platforms like Windows and MacOS
// sandboxed where we can't spawn user-specific sub-processes and instead
// rely on the UI application that's already running as an unprivileged
// user to access the filesystem for us.
func (s *FileSystemForRemote) SetFileServerAddr(addr string) {
s.mu.Lock()
s.fileServerAddr = addr
s.mu.Unlock()
}
// SetShares sets the complete set of shares exposed by this node. If
// AllowShareAs() reports true, we will use one subprocess per user to
// access the filesystem (see userServer). Otherwise, we will use the file
// server configured via SetFileServerAddr.
func (s *FileSystemForRemote) SetShares(shares map[string]*Share) {
userServers := make(map[string]*userServer)
if AllowShareAs() {
// set up per-user server
for _, share := range shares {
p, found := userServers[share.As]
if !found {
p = &userServer{
logf: s.logf,
}
userServers[share.As] = p
}
p.shares = append(p.shares, share)
}
for _, p := range userServers {
go p.runLoop()
}
}
fileSystems := make(map[string]webdav.FileSystem, len(shares))
for _, share := range shares {
fileSystems[share.Name] = s.buildWebDAVFS(share)
}
s.mu.Lock()
s.shares = shares
oldFileSystems := s.fileSystems
oldUserServers := s.userServers
s.fileSystems = fileSystems
s.userServers = userServers
s.mu.Unlock()
s.stopUserServers(oldUserServers)
s.closeFileSystems(oldFileSystems)
}
func (s *FileSystemForRemote) buildWebDAVFS(share *Share) webdav.FileSystem {
return webdavfs.New(webdavfs.Options{
Logf: s.logf,
URL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), share.Name),
Transport: &http.Transport{
Dial: func(_, shareAddr string) (net.Conn, error) {
shareNameHex, _, err := net.SplitHostPort(shareAddr)
if err != nil {
return nil, fmt.Errorf("unable to parse share address %v: %w", shareAddr, err)
}
// We had to encode the share name in hex to make sure it's a valid hostname
shareNameBytes, err := hex.DecodeString(shareNameHex)
if err != nil {
return nil, fmt.Errorf("unable to decode share name from host %v: %v", shareNameHex, err)
}
shareName := string(shareNameBytes)
s.mu.RLock()
share, shareFound := s.shares[shareName]
userServers := s.userServers
fileServerAddr := s.fileServerAddr
s.mu.RUnlock()
if !shareFound {
return nil, fmt.Errorf("unknown share %v", shareName)
}
var addr string
if !AllowShareAs() {
addr = fileServerAddr
} else {
userServer, found := userServers[share.As]
if found {
userServer.mu.RLock()
addr = userServer.addr
userServer.mu.RUnlock()
}
}
if addr == "" {
return nil, fmt.Errorf("unable to determine address for share %v", shareName)
}
_, err = netip.ParseAddrPort(addr)
if err == nil {
// this is a regular network address, dial normally
return net.Dial("tcp", addr)
}
// assume this is a safesocket address
return safesocket.Connect(addr)
},
},
StatRoot: true,
})
}
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
// also accepts a Permissions map that captures the permissions of the
// connecting node.
func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request) {
isWrite := writeMethods[r.Method]
if isWrite {
share := shared.CleanAndSplit(r.URL.Path)[0]
switch permissions.For(share) {
case PermissionNone:
// If we have no permissions to this share, treat it as not found
// to avoid leaking any information about the share's existence.
http.Error(w, "not found", http.StatusNotFound)
return
case PermissionReadOnly:
http.Error(w, "permission denied", http.StatusForbidden)
return
}
}
s.mu.RLock()
fileSystems := s.fileSystems
s.mu.RUnlock()
children := make([]*compositefs.Child, 0, len(fileSystems))
// filter out shares to which the connecting principal has no access
for name, fs := range fileSystems {
if permissions.For(name) == PermissionNone {
continue
}
children = append(children, &compositefs.Child{Name: name, FS: fs})
}
cfs := compositefs.New(
compositefs.Options{
Logf: s.logf,
StatChildren: true,
})
cfs.SetChildren(children...)
h := webdav.Handler{
FileSystem: cfs,
LockSystem: s.lockSystem,
}
h.ServeHTTP(w, r)
}
func (s *FileSystemForRemote) stopUserServers(userServers map[string]*userServer) {
for _, server := range userServers {
if err := server.Close(); err != nil {
s.logf("error closing tailfs user server: %v", err)
}
}
}
func (s *FileSystemForRemote) closeFileSystems(fileSystems map[string]webdav.FileSystem) {
for _, fs := range fileSystems {
closer, ok := fs.(interface{ Close() error })
if ok {
if err := closer.Close(); err != nil {
s.logf("error closing tailfs filesystem: %v", err)
}
}
}
}
// Close() stops serving the WebDAV content
func (s *FileSystemForRemote) Close() error {
s.mu.Lock()
userServers := s.userServers
fileSystems := s.fileSystems
s.mu.Unlock()
s.stopUserServers(userServers)
s.closeFileSystems(fileSystems)
return nil
}
// userServer runs tailscaled serve-tailfs to serve webdav content for the
// given Shares. All Shares are assumed to have the same Share.As, and the
// content is served as that Share.As user.
type userServer struct {
logf logger.Logf
shares []*Share
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
cmd *exec.Cmd
addr string
closed bool
}
func (s *userServer) Close() error {
s.mu.Lock()
cmd := s.cmd
s.closed = true
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
return cmd.Process.Kill()
}
// not running, that's okay
return nil
}
func (s *userServer) runLoop() {
executable, err := os.Executable()
if err != nil {
s.logf("can't find executable: %v", err)
return
}
maxSleepTime := 30 * time.Second
consecutiveFailures := float64(0)
var timeOfLastFailure time.Time
for {
s.mu.RLock()
closed := s.closed
s.mu.RUnlock()
if closed {
return
}
err := s.run(executable)
now := time.Now()
timeSinceLastFailure := now.Sub(timeOfLastFailure)
timeOfLastFailure = now
if timeSinceLastFailure < maxSleepTime {
consecutiveFailures++
} else {
consecutiveFailures = 1
}
sleepTime := time.Duration(math.Pow(2, consecutiveFailures)) * time.Millisecond
if sleepTime > maxSleepTime {
sleepTime = maxSleepTime
}
s.logf("user server % v stopped with error %v, will try again in %v", executable, err, sleepTime)
time.Sleep(sleepTime)
}
}
// Run runs the executable (tailscaled). This function only works on UNIX systems,
// but those are the only ones on which we use userServers anyway.
func (s *userServer) run(executable string) error {
// set up the command
args := []string{"serve-tailfs"}
for _, s := range s.shares {
args = append(args, s.Name, s.Path)
}
allArgs := []string{"-u", s.shares[0].As, executable}
allArgs = append(allArgs, args...)
cmd := exec.Command("sudo", allArgs...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
defer stdout.Close()
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr pipe: %w", err)
}
defer stderr.Close()
err = cmd.Start()
if err != nil {
return fmt.Errorf("start: %w", err)
}
s.mu.Lock()
s.cmd = cmd
s.mu.Unlock()
// read address
stdoutScanner := bufio.NewScanner(stdout)
stdoutScanner.Scan()
if stdoutScanner.Err() != nil {
return fmt.Errorf("read addr: %w", stdoutScanner.Err())
}
addr := stdoutScanner.Text()
// send the rest of stdout and stderr to logger to avoid blocking
go func() {
for stdoutScanner.Scan() {
s.logf("tailscaled serve-tailfs stdout: %v", stdoutScanner.Text())
}
}()
stderrScanner := bufio.NewScanner(stderr)
go func() {
for stderrScanner.Scan() {
s.logf("tailscaled serve-tailfs stderr: %v", stderrScanner.Text())
}
}()
s.mu.Lock()
s.addr = strings.TrimSpace(addr)
s.mu.Unlock()
return cmd.Wait()
}
var writeMethods = map[string]bool{
"PUT": true,
"POST": true,
"COPY": true,
"LOCK": true,
"UNLOCK": true,
"MKCOL": true,
"MOVE": true,
"PROPPATCH": true,
}

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !unix
package tailfs
func doAllowShareAs() bool {
// On non-UNIX platforms, we use the GUI application (e.g. Windows taskbar
// icon) to access the filesystem as whatever unprivileged user is running
// the GUI app, so we cannot allow sharing as a different user.
return false
}

@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"encoding/json"
"fmt"
)
type Permission uint8
const (
PermissionNone Permission = iota
PermissionReadOnly
PermissionReadWrite
)
const (
accessReadOnly = "ro"
accessReadWrite = "rw"
wildcardShare = "*"
)
// Permissions represents the set of permissions for a given principal to a
// set of shares.
type Permissions map[string]Permission
type grant struct {
Shares []string
Access string
}
// ParsePermissions builds a Permissions map from a lis of raw grants.
func ParsePermissions(rawGrants [][]byte) (Permissions, error) {
permissions := make(Permissions)
for _, rawGrant := range rawGrants {
var g grant
err := json.Unmarshal(rawGrant, &g)
if err != nil {
return nil, fmt.Errorf("unmarshal raw grants: %v", err)
}
for _, share := range g.Shares {
existingPermission := permissions[share]
permission := PermissionReadOnly
if g.Access == accessReadWrite {
permission = PermissionReadWrite
}
if permission > existingPermission {
permissions[share] = permission
}
}
}
return permissions, nil
}
func (p Permissions) For(share string) Permission {
specific := p[share]
wildcard := p[wildcardShare]
if specific > wildcard {
return specific
}
return wildcard
}

@ -0,0 +1,61 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"encoding/json"
"testing"
)
func TestPermissions(t *testing.T) {
tests := []struct {
perms []grant
share string
want Permission
}{
{[]grant{
{Shares: []string{"*"}, Access: "ro"},
{Shares: []string{"a"}, Access: "rw"},
},
"a",
PermissionReadWrite,
},
{[]grant{
{Shares: []string{"*"}, Access: "ro"},
{Shares: []string{"a"}, Access: "rw"},
},
"b",
PermissionReadOnly,
},
{[]grant{
{Shares: []string{"a"}, Access: "rw"},
},
"c",
PermissionNone,
},
}
for _, tt := range tests {
t.Run(tt.share, func(t *testing.T) {
var rawPerms [][]byte
for _, perm := range tt.perms {
b, err := json.Marshal(perm)
if err != nil {
t.Fatal(err)
}
rawPerms = append(rawPerms, b)
}
p, err := ParsePermissions(rawPerms)
if err != nil {
t.Fatal(err)
}
got := p.For(tt.share)
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}

@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build unix
package tailfs
import "tailscale.com/version"
func doAllowShareAs() bool {
// All UNIX platforms use user servers (sub-processes) to access the OS
// filesystem as a specific unprivileged users, except for sandboxed macOS
// which doesn't support impersonating users and instead accesses files
// through the macOS GUI app as whatever unprivileged user is running it.
return !version.IsSandboxedMacOS()
}

@ -0,0 +1,42 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"path"
"strings"
)
// This file provides utility functions for working with URL paths. These are
// similar to functions in package path in the standard library, but differ in
// ways that are documented on the relevant functions.
const (
sepString = "/"
sepStringAndDot = "/."
sep = '/'
)
// CleanAndSplit cleans the provided path p and splits it into its constituent
// parts. This is different from path.Split which just splits a path into prefix
// and suffix.
func CleanAndSplit(p string) []string {
return strings.Split(strings.Trim(path.Clean(p), sepStringAndDot), sepString)
}
// Join behaves like path.Join() but also includes a leading slash.
func Join(parts ...string) string {
fullParts := make([]string, 0, len(parts))
fullParts = append(fullParts, sepString)
for _, part := range parts {
fullParts = append(fullParts, part)
}
return path.Join(fullParts...)
}
// IsRoot determines whether a given path p is the root path, defined as either
// empty or "/".
func IsRoot(p string) bool {
return p == "" || p == sepString
}

@ -0,0 +1,57 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"reflect"
"testing"
)
func TestCleanAndSplit(t *testing.T) {
tests := []struct {
path string
want []string
}{
{"", []string{""}},
{"/", []string{""}},
{"//", []string{""}},
{"a", []string{"a"}},
{"/a", []string{"a"}},
{"a/", []string{"a"}},
{"/a/", []string{"a"}},
{"a/b", []string{"a", "b"}},
{"/a/b", []string{"a", "b"}},
{"a/b/", []string{"a", "b"}},
{"/a/b/", []string{"a", "b"}},
{"/a/../b", []string{"b"}},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := CleanAndSplit(tt.path); !reflect.DeepEqual(tt.want, got) {
t.Errorf("CleanAndSplit(%q) = %v; want %v", tt.path, got, tt.want)
}
})
}
}
func TestJoin(t *testing.T) {
tests := []struct {
parts []string
want string
}{
{[]string{""}, "/"},
{[]string{"a"}, "/a"},
{[]string{"/a"}, "/a"},
{[]string{"/a/"}, "/a"},
{[]string{"/a/", "/b/"}, "/a/b"},
{[]string{"/a/../b", "c"}, "/b/c"},
}
for _, tt := range tests {
t.Run(Join(tt.parts...), func(t *testing.T) {
if got := Join(tt.parts...); !reflect.DeepEqual(tt.want, got) {
t.Errorf("Join(%v) = %q; want %q", tt.parts, got, tt.want)
}
})
}
}

@ -0,0 +1,111 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package shared contains types and functions shared by different tailfs
// packages.
package shared
import (
"errors"
"io"
"io/fs"
"sync"
)
// DirFile implements webdav.File for a virtual directory.
// It mimics the behavior of an os.File that is pointing at a real directory.
type DirFile struct {
// Info provides the fs.FileInfo for this directory
Info fs.FileInfo
// LoadChildren is used to load the fs.FileInfos for this directory's
// children. It is called at most once in order to support listing
// children.
LoadChildren func() ([]fs.FileInfo, error)
// loadChildrenMu guards children and loadedChildren.
loadChildrenMu sync.Mutex
children []fs.FileInfo
loadedChildren bool
}
// Readdir implements interface webdav.File. It lazily loads information about
// children when it is called.
func (d *DirFile) Readdir(count int) ([]fs.FileInfo, error) {
err := d.loadChildrenIfNecessary()
if err != nil {
return nil, err
}
if count <= 0 {
result := d.children
d.children = nil
return result, nil
}
n := len(d.children)
if count < n {
n = count
}
result := d.children[:n]
d.children = d.children[n:]
if len(d.children) == 0 {
err = io.EOF
}
return result, err
}
func (d *DirFile) loadChildrenIfNecessary() error {
d.loadChildrenMu.Lock()
defer d.loadChildrenMu.Unlock()
if !d.loadedChildren {
var err error
d.children, err = d.LoadChildren()
if err != nil {
return err
}
d.loadedChildren = true
}
return nil
}
// Stat implements interface webdav.File.
func (d *DirFile) Stat() (fs.FileInfo, error) {
return d.Info, nil
}
// Close implements interface webdav.File. It does nothing and never returns an
// error.
func (d *DirFile) Close() error {
return nil
}
// Read implements interface webdav.File. As this is a directory, it always
// fails with an fs.PathError.
func (d *DirFile) Read(b []byte) (int, error) {
return 0, &fs.PathError{
Op: "read",
Path: d.Info.Name(),
Err: errors.New("is a directory"),
}
}
// Write implements interface webdav.File. As this is a directory, it always
// fails with an fs.PathError.
func (d *DirFile) Write(b []byte) (int, error) {
return 0, &fs.PathError{
Op: "write",
Path: d.Info.Name(),
Err: errors.New("bad file descriptor"),
}
}
// Seek implements interface webdav.File. As this is a directory, it always
// fails with an fs.PathError.
func (d *DirFile) Seek(offset int64, whence int) (int64, error) {
return 0, &fs.PathError{
Op: "seek",
Path: d.Info.Name(),
Err: errors.New("invalid argument"),
}
}

@ -0,0 +1,73 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package shared
import (
"context"
"io/fs"
"os"
"time"
"github.com/tailscale/xnet/webdav"
)
// StaticFileInfo implements a static fs.FileInfo
type StaticFileInfo struct {
// Named controls Name()
Named string
// Sized controls Size()
Sized int64
// Moded controls Mode()
Moded os.FileMode
// BirthedTime controls BirthTime()
BirthedTime time.Time
// BirthedTimeErr stores any error encountered when trying to get BirthTime
BirthedTimeErr error
// ModdedTime controls ModTime()
ModdedTime time.Time
// Dir controls IsDir()
Dir bool
}
// BirthTime implements webdav.BirthTimer
func (fi *StaticFileInfo) BirthTime(_ context.Context) (time.Time, error) {
return fi.BirthedTime, fi.BirthedTimeErr
}
func (fi *StaticFileInfo) Name() string { return fi.Named }
func (fi *StaticFileInfo) Size() int64 { return fi.Sized }
func (fi *StaticFileInfo) Mode() os.FileMode { return fi.Moded }
func (fi *StaticFileInfo) ModTime() time.Time { return fi.ModdedTime }
func (fi *StaticFileInfo) IsDir() bool { return fi.Dir }
func (fi *StaticFileInfo) Sys() any { return nil }
func RenamedFileInfo(ctx context.Context, name string, fi fs.FileInfo) *StaticFileInfo {
var birthTime time.Time
var birthTimeErr error
birthTimer, ok := fi.(webdav.BirthTimer)
if ok {
birthTime, birthTimeErr = birthTimer.BirthTime(ctx)
}
return &StaticFileInfo{
Named: name,
Sized: fi.Size(),
Moded: fi.Mode(),
BirthedTime: birthTime,
BirthedTimeErr: birthTimeErr,
ModdedTime: fi.ModTime(),
Dir: fi.IsDir(),
}
}
// ReadOnlyDirInfo returns a static fs.FileInfo for a read-only directory
func ReadOnlyDirInfo(name string, ts time.Time) *StaticFileInfo {
return &StaticFileInfo{
Named: name,
Sized: 0,
Moded: 0555,
BirthedTime: ts,
ModdedTime: ts,
Dir: true,
}
}

@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package tailfs provides a filesystem that allows sharing folders between
// Tailscale nodes using WebDAV.
package tailfs
import (
"time"
)
const (
// statCacheTTL causes the local WebDAV proxy to cache file metadata to
// avoid excessive network roundtrips. This is similar to the
// DirectoryCacheLifetime setting of Windows' built-in SMB client,
// see https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-7/ff686200(v=ws.10)
statCacheTTL = 10 * time.Second
)

@ -0,0 +1,597 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailfs
import (
"context"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path"
"path/filepath"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
"tailscale.com/tailfs/webdavfs"
"tailscale.com/tstest"
)
const (
domain = `test$%domain.com`
remote1 = `remote$%1`
remote2 = `_remote$%2`
share11 = `share$%11`
share12 = `_share$%12`
file111 = `file$%111.txt`
)
func init() {
// set AllowShareAs() to false so that we don't try to use sub-processes
// for access files on disk.
disallowShareAs = true
}
// The tests in this file simulate real-life Tailfs scenarios, but without
// going over the Tailscale network stack.
func TestDirectoryListing(t *testing.T) {
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.checkDirList("root directory should contain the one and only domain once a remote has been set", "/", domain)
s.checkDirList("domain should contain its only remote", shared.Join(domain), remote1)
s.checkDirList("remote with no shares should be empty", shared.Join(domain, remote1))
s.addShare(remote1, share11, PermissionReadWrite)
s.checkDirList("remote with one share should contain that share", shared.Join(domain, remote1), share11)
s.addShare(remote1, share12, PermissionReadOnly)
s.checkDirList("remote with two shares should contain both in lexicographical order", shared.Join(domain, remote1), share12, share11)
s.checkDirListIncremental("remote with two shares should contain both in lexicographical order even when reading directory incrementally", shared.Join(domain, remote1), share12, share11)
s.addRemote(remote2)
s.checkDirList("domain with two remotes should contain both in lexicographical order", shared.Join(domain), remote2, remote1)
s.freezeRemote(remote1)
s.checkDirList("domain with two remotes should contain both in lexicographical order even if one is unreachable", shared.Join(domain), remote2, remote1)
s.checkDirList("directory listing for offline remote should return empty list", shared.Join(domain, remote1))
s.unfreezeRemote(remote1)
s.checkDirList("attempt at lateral traversal should simply list shares", shared.Join(domain, remote1, share11, ".."), share12, share11)
}
func TestFileManipulation(t *testing.T) {
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
s.checkFileStatus(remote1, share11, file111)
s.checkFileContents(remote1, share11, file111)
s.addShare(remote1, share12, PermissionReadOnly)
s.writeFile("writing file to read-only remote should fail", remote1, share12, file111, "hello world", false)
s.writeFile("writing file to non-existent remote should fail", "non-existent", share11, file111, "hello world", false)
s.writeFile("writing file to non-existent share should fail", remote1, "non-existent", file111, "hello world", false)
}
func TestFileOps(t *testing.T) {
ctx := context.Background()
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
fi, err := s.fs.Stat(context.Background(), pathTo(remote1, share11, file111))
if err != nil {
t.Fatalf("failed to Stat: %s", err)
}
bt, ok := fi.(webdav.BirthTimer)
if !ok {
t.Fatal("FileInfo should be a BirthTimer")
}
birthTime, err := bt.BirthTime(ctx)
if err != nil {
t.Fatalf("failed to BirthTime: %s", err)
}
if birthTime.IsZero() {
t.Fatal("BirthTime() should return a non-zero time")
}
_, err = s.fs.OpenFile(ctx, pathTo(remote1, share11, "nonexistent.txt"), os.O_RDONLY, 0)
if err == nil {
t.Fatal("opening non-existent file for read should fail")
}
dir, err := s.fs.OpenFile(ctx, shared.Join(domain, remote1), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed to open directory for read: %s", err)
}
defer dir.Close()
_, err = dir.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("seeking in directory should fail")
}
_, err = dir.Read(make([]byte, 8))
if err == nil {
t.Fatal("reading bytes from directory should fail")
}
_, err = dir.Write(make([]byte, 8))
if err == nil {
t.Fatal("writing bytes to directory should fail")
}
readOnlyFile, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed to open file for read: %s", err)
}
defer readOnlyFile.Close()
n, err := readOnlyFile.Seek(0, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek 0 from start of read-only file: %s", err)
}
if n != 0 {
t.Fatal("seeking 0 from start of read-only file should return 0")
}
n, err = readOnlyFile.Seek(1, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek 1 from start of read-only file: %s", err)
}
if n != 1 {
t.Fatal("seeking 1 from start of read-only file should return 1")
}
n, err = readOnlyFile.Seek(0, io.SeekEnd)
if err != nil {
t.Fatalf("failed to seek 0 from end of read-only file: %s", err)
}
if n != fi.Size() {
t.Fatal("seeking 0 from end of read-only file should return file size")
}
_, err = readOnlyFile.Seek(1, io.SeekEnd)
if err == nil {
t.Fatal("seeking 1 from end of read-only file should fail")
}
_, err = readOnlyFile.Seek(0, io.SeekCurrent)
if err == nil {
t.Fatal("seeking from current of read-only file should fail")
}
_, err = readOnlyFile.Write(make([]byte, 8))
if err == nil {
t.Fatal("writing bytes to read-only file should fail")
}
writeOnlyFile, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0)
if err != nil {
t.Fatalf("failed to OpenFile for write: %s", err)
}
defer writeOnlyFile.Close()
_, err = writeOnlyFile.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("seeking in write only file should fail")
}
_, err = writeOnlyFile.Read(make([]byte, 8))
if err == nil {
t.Fatal("reading bytes from a write only file should fail")
}
}
func TestFileRewind(t *testing.T) {
ctx := context.Background()
s := newSystem(t)
defer s.stop()
s.addRemote(remote1)
s.addShare(remote1, share11, PermissionReadWrite)
// Create a file slightly longer than our max rewind buffer of 512
fileLength := webdavfs.MaxRewindBuffer + 1
data := make([]byte, fileLength)
for i := 0; i < fileLength; i++ {
data[i] = byte(i % 256)
}
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, string(data), true)
// Try reading and rewinding in every size up to the maximum buffer length
for i := 0; i < webdavfs.MaxRewindBuffer; i++ {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
f, err := s.fs.OpenFile(ctx, pathTo(remote1, share11, file111), os.O_RDONLY, 0)
if err != nil {
t.Fatalf("failed top OpenFile for read: %s", err)
}
defer f.Close()
b := make([]byte, fileLength)
n, err := io.ReadFull(f, b[:i])
if err != nil {
t.Fatalf("failed to read first %d bytes from file: %s", i, err)
}
if n != i {
log.Fatalf("Reading first %d bytes should report correct count, but reported %d", i, n)
}
_, err = f.Seek(0, io.SeekStart)
if err != nil {
t.Fatalf("failed to seek back %d bytes: %s", i, err)
}
n, err = io.ReadFull(f, b)
if err != nil {
t.Fatalf("failed to read full file: %s", err)
}
if n != fileLength {
t.Fatalf("reading full file reported incorrect count, got %d, want %d", n, fileLength)
}
if string(b) != string(data) {
t.Fatalf("read wrong data, got %q, want %q", b, data)
}
_, err = f.Seek(0, io.SeekStart)
if err == nil {
t.Fatal("Attempting to seek to beginning of file after having read past rewind buffer should fail")
}
})
}
}
type local struct {
l net.Listener
fs *FileSystemForLocal
}
type remote struct {
l net.Listener
fs *FileSystemForRemote
fileServer *FileServer
shares map[string]string
permissions map[string]Permission
mu sync.RWMutex
}
func (r *remote) freeze() {
r.mu.Lock()
}
func (r *remote) unfreeze() {
r.mu.Unlock()
}
func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.RLock()
defer r.mu.RUnlock()
r.fs.ServeHTTPWithPerms(r.permissions, w, req)
}
type system struct {
t *testing.T
local *local
fs webdav.FileSystem
remotes map[string]*remote
}
func newSystem(t *testing.T) *system {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
fs := NewFileSystemForLocal(log.Printf)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to Listen: %s", err)
}
t.Logf("FileSystemForLocal listening at %s", l.Addr())
go func() {
for {
conn, err := l.Accept()
if err != nil {
t.Logf("Accept: %v", err)
return
}
go fs.HandleConn(conn, conn.RemoteAddr())
}
}()
return &system{
t: t,
local: &local{l: l, fs: fs},
fs: webdavfs.New(webdavfs.Options{
URL: fmt.Sprintf("http://%s", l.Addr()),
Transport: &http.Transport{DisableKeepAlives: true},
}),
remotes: make(map[string]*remote),
}
}
func (s *system) addRemote(name string) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
s.t.Fatalf("failed to Listen: %s", err)
}
s.t.Logf("Remote for %v listening at %s", name, l.Addr())
fileServer, err := NewFileServer()
if err != nil {
s.t.Fatalf("failed to call NewFileServer: %s", err)
}
go fileServer.Serve()
s.t.Logf("FileServer for %v listening at %s", name, fileServer.Addr())
r := &remote{
l: l,
fileServer: fileServer,
fs: NewFileSystemForRemote(log.Printf),
shares: make(map[string]string),
permissions: make(map[string]Permission),
}
r.fs.SetFileServerAddr(fileServer.Addr())
go http.Serve(l, r)
s.remotes[name] = r
remotes := make([]*Remote, 0, len(s.remotes))
for name, r := range s.remotes {
remotes = append(remotes, &Remote{
Name: name,
URL: fmt.Sprintf("http://%s", r.l.Addr()),
})
}
s.local.fs.SetRemotes(domain, remotes, &http.Transport{})
}
func (s *system) addShare(remoteName, shareName string, permission Permission) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
f := s.t.TempDir()
r.shares[shareName] = f
r.permissions[shareName] = permission
shares := make(map[string]*Share, len(r.shares))
for shareName, folder := range r.shares {
shares[shareName] = &Share{
Name: shareName,
Path: folder,
}
}
r.fs.SetShares(shares)
r.fileServer.SetShares(r.shares)
}
func (s *system) freezeRemote(remoteName string) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
r.freeze()
}
func (s *system) unfreezeRemote(remoteName string) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
r.unfreeze()
}
func (s *system) writeFile(label, remoteName, shareName, name, contents string, expectSuccess bool) {
path := pathTo(remoteName, shareName, name)
file, err := s.fs.OpenFile(context.Background(), path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if expectSuccess && err != nil {
s.t.Fatalf("%v: expected success writing file %q, but got error %v", label, path, err)
}
defer func() {
if !expectSuccess && err == nil {
s.t.Fatalf("%v: expected error writing file %q", label, path)
}
}()
defer func() {
err = file.Close()
if expectSuccess && err != nil {
s.t.Fatalf("error closing %v: %v", path, err)
}
}()
_, err = file.Write([]byte(contents))
if expectSuccess && err != nil {
s.t.Fatalf("%v: writing file %q: %v", label, path, err)
}
}
func (s *system) checkFileStatus(remoteName, shareName, name string) {
expectedFI := s.stat(remoteName, shareName, name)
actualFI := s.statViaWebDAV(remoteName, shareName, name)
s.checkFileInfosEqual(expectedFI, actualFI, fmt.Sprintf("%s/%s/%s should show same FileInfo via WebDAV stat as local stat", remoteName, shareName, name))
}
func (s *system) checkFileContents(remoteName, shareName, name string) {
expected := s.read(remoteName, shareName, name)
actual := s.readViaWebDAV(remoteName, shareName, name)
if expected != actual {
s.t.Errorf("%s/%s/%s should show same contents via WebDAV read as local read\nwant: %q\nhave: %q", remoteName, shareName, name, expected, actual)
}
}
func (s *system) checkDirList(label string, path string, want ...string) {
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err)
}
got, err := file.Readdir(0)
if err != nil {
s.t.Fatalf("failed to Readdir: %s", err)
}
if len(want) == 0 && len(got) == 0 {
return
}
gotNames := make([]string, 0, len(got))
for _, fi := range got {
gotNames = append(gotNames, fi.Name())
}
if diff := cmp.Diff(want, gotNames); diff != "" {
s.t.Errorf("%v: (-got, +want):\n%s", label, diff)
}
}
func (s *system) checkDirListIncremental(label string, path string, want ...string) {
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatal(err)
}
var gotNames []string
for {
got, err := file.Readdir(1)
for _, fi := range got {
gotNames = append(gotNames, fi.Name())
}
if err == io.EOF {
break
}
if err != nil {
s.t.Fatalf("failed to Readdir: %s", err)
}
}
if len(want) == 0 && len(gotNames) == 0 {
return
}
if diff := cmp.Diff(want, gotNames); diff != "" {
s.t.Errorf("%v: (-got, +want):\n%s", label, diff)
}
}
func (s *system) stat(remoteName, shareName, name string) os.FileInfo {
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
fi, err := os.Stat(filename)
if err != nil {
s.t.Fatalf("failed to Stat: %s", err)
}
return fi
}
func (s *system) statViaWebDAV(remoteName, shareName, name string) os.FileInfo {
path := pathTo(remoteName, shareName, name)
fi, err := s.fs.Stat(context.Background(), path)
if err != nil {
s.t.Fatalf("failed to Stat: %s", err)
}
return fi
}
func (s *system) read(remoteName, shareName, name string) string {
filename := filepath.Join(s.remotes[remoteName].shares[shareName], name)
b, err := os.ReadFile(filename)
if err != nil {
s.t.Fatalf("failed to ReadFile: %s", err)
}
return string(b)
}
func (s *system) readViaWebDAV(remoteName, shareName, name string) string {
path := pathTo(remoteName, shareName, name)
file, err := s.fs.OpenFile(context.Background(), path, os.O_RDONLY, 0)
if err != nil {
s.t.Fatalf("failed to OpenFile: %s", err)
}
defer file.Close()
b, err := io.ReadAll(file)
if err != nil {
s.t.Fatalf("failed to ReadAll: %s", err)
}
return string(b)
}
func (s *system) stop() {
err := s.local.fs.Close()
if err != nil {
s.t.Fatalf("failed to Close fs: %s", err)
}
err = s.local.l.Close()
if err != nil {
s.t.Fatalf("failed to Close listener: %s", err)
}
for _, r := range s.remotes {
err = r.fs.Close()
if err != nil {
s.t.Fatalf("failed to Close remote fs: %s", err)
}
err = r.l.Close()
if err != nil {
s.t.Fatalf("failed to Close remote listener: %s", err)
}
err = r.fileServer.Close()
if err != nil {
s.t.Fatalf("failed to Close remote fileserver: %s", err)
}
}
}
func (s *system) checkFileInfosEqual(expected, actual fs.FileInfo, label string) {
if expected == nil && actual == nil {
return
}
diff := cmp.Diff(fileInfoToStatic(expected, true), fileInfoToStatic(actual, false))
if diff != "" {
s.t.Errorf("%v (-got, +want):\n%s", label, diff)
}
}
func fileInfoToStatic(fi fs.FileInfo, fixupMode bool) fs.FileInfo {
mode := fi.Mode()
if fixupMode {
// WebDAV doesn't transmit file modes, so we just mimic the defaults that
// our WebDAV client uses.
mode = os.FileMode(0664)
if fi.IsDir() {
mode = 0775 | os.ModeDir
}
}
return &shared.StaticFileInfo{
Named: fi.Name(),
Sized: fi.Size(),
Moded: mode,
ModdedTime: fi.ModTime().Truncate(1 * time.Second).UTC(),
Dir: fi.IsDir(),
}
}
func pathTo(remote, share, name string) string {
return path.Join(domain, remote, share, name)
}

@ -0,0 +1,192 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"context"
"errors"
"io"
"io/fs"
"os"
"sync"
"github.com/tailscale/gowebdav"
)
const (
// MaxRewindBuffer specifies the size of the rewind buffer for reading
// from files. For some files, net/http performs content type detection
// by reading up to the first 512 bytes of a file, then seeking back to the
// beginning before actually transmitting the file. To support this, we
// maintain a rewind buffer of 512 bytes.
MaxRewindBuffer = 512
)
type readOnlyFile struct {
name string
client *gowebdav.Client
rewindBuffer []byte
position int
// mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them.
mu sync.RWMutex
io.ReadCloser
initialFI fs.FileInfo
fi fs.FileInfo
}
// Readdir implements webdav.File. Since this is a file, it always failes with
// an os.PathError.
func (f *readOnlyFile) Readdir(count int) ([]fs.FileInfo, error) {
return nil, &os.PathError{
Op: "readdir",
Path: f.fi.Name(),
Err: errors.New("is a file"), // TODO(oxtoacart): make sure this and below errors match what a regular os.File does
}
}
// Seek implements webdav.File. Only the specific types of seek used by the
// webdav package are implemented, namely:
//
// - Seek to 0 from end of file
// - Seek to 0 from beginning of file, provided that fewer than 512 bytes
// have already been read.
// - Seek to n from beginning of file, provided that no bytes have already
// been read.
//
// Any other type of seek will fail with an os.PathError.
func (f *readOnlyFile) Seek(offset int64, whence int) (int64, error) {
err := f.statIfNecessary()
if err != nil {
return 0, err
}
switch whence {
case io.SeekEnd:
if offset == 0 {
// seek to end is usually done to check size, let's play along
size := f.fi.Size()
return size, nil
}
case io.SeekStart:
if offset == 0 {
// this is usually done to start reading after getting size
if f.position > MaxRewindBuffer {
return 0, errors.New("attempted seek after having read past rewind buffer")
}
f.position = 0
return 0, nil
} else if f.position == 0 {
// this is usually done to perform a range request to skip the head of the file
f.position = int(offset)
return offset, nil
}
}
// unknown seek scenario, error out
return 0, &os.PathError{
Op: "seek",
Path: f.fi.Name(),
Err: errors.New("seek not supported"),
}
}
// Stat implements webdav.File, returning either the FileInfo with which this
// file was initialized, or the more recently fetched FileInfo if available.
func (f *readOnlyFile) Stat() (fs.FileInfo, error) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.fi != nil {
return f.fi, nil
}
return f.initialFI, nil
}
// Read implements webdav.File.
func (f *readOnlyFile) Read(p []byte) (int, error) {
err := f.initReaderIfNecessary()
if err != nil {
return 0, err
}
amountToReadFromBuffer := len(f.rewindBuffer) - f.position
if amountToReadFromBuffer > 0 {
n := copy(p, f.rewindBuffer)
f.position += n
return n, nil
}
n, err := f.ReadCloser.Read(p)
if n > 0 && f.position < MaxRewindBuffer {
amountToReadIntoBuffer := MaxRewindBuffer - f.position
if amountToReadIntoBuffer > n {
amountToReadIntoBuffer = n
}
f.rewindBuffer = append(f.rewindBuffer, p[:amountToReadIntoBuffer]...)
}
f.position += n
return n, err
}
// Write implements webdav.File. As this file is read-only, it always fails
// with an os.PathError.
func (f *readOnlyFile) Write(p []byte) (int, error) {
return 0, &os.PathError{
Op: "write",
Path: f.fi.Name(),
Err: errors.New("read-only"),
}
}
// Close implements webdav.File.
func (f *readOnlyFile) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.ReadCloser == nil {
return nil
}
return f.ReadCloser.Close()
}
// statIfNecessary lazily initializes the FileInfo, bypassing the stat cache to
// make sure we have fresh info before trying to read the file.
func (f *readOnlyFile) statIfNecessary() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fi == nil {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
var err error
f.fi, err = f.client.Stat(ctxWithTimeout, f.name)
if err != nil {
return translateWebDAVError(err)
}
}
return nil
}
// initReaderIfNecessary initializes the Reader if it hasn't been opened yet. We
// do this lazily because github.com/tailscale/xnet/webdav often opens files in
// read-only mode without ever actually reading from them, so we can improve
// performance by avoiding the round-trip to the server.
func (f *readOnlyFile) initReaderIfNecessary() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.ReadCloser == nil {
var err error
f.ReadCloser, err = f.client.ReadStreamOffset(context.Background(), f.name, f.position)
if err != nil {
return translateWebDAVError(err)
}
}
return nil
}

@ -0,0 +1,71 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"io/fs"
"path/filepath"
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
)
// statCache provides a cache for file directory and file metadata. Especially
// when used from the command-line, mapped WebDAV drives can generate
// repetitive requests for the same file metadata. This cache helps reduce the
// number of round-trips to the WebDAV server for such requests.
type statCache struct {
// mu guards the below values.
mu sync.Mutex
cache *ttlcache.Cache[string, fs.FileInfo]
}
func newStatCache(ttl time.Duration) *statCache {
cache := ttlcache.New(
ttlcache.WithTTL[string, fs.FileInfo](ttl),
)
go cache.Start()
return &statCache{cache: cache}
}
func (c *statCache) getOrFetch(name string, fetch func(string) (fs.FileInfo, error)) (fs.FileInfo, error) {
c.mu.Lock()
item := c.cache.Get(name)
c.mu.Unlock()
if item != nil {
return item.Value(), nil
}
fi, err := fetch(name)
if err == nil {
c.mu.Lock()
c.cache.Set(name, fi, ttlcache.DefaultTTL)
c.mu.Unlock()
}
return fi, err
}
func (c *statCache) set(parentPath string, infos []fs.FileInfo) {
c.mu.Lock()
defer c.mu.Unlock()
for _, info := range infos {
path := filepath.Join(parentPath, filepath.Base(info.Name()))
c.cache.Set(path, info, ttlcache.DefaultTTL)
}
}
func (c *statCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache.DeleteAll()
}
func (c *statCache) stop() {
c.cache.Stop()
}

@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"io/fs"
"os"
"path/filepath"
"testing"
"time"
"tailscale.com/tailfs/shared"
"tailscale.com/tstest"
)
func TestStatCache(t *testing.T) {
// Make sure we don't leak goroutines
tstest.ResourceCheck(t)
dir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
// create file of size 1
filename := filepath.Join(dir, "thefile")
err = os.WriteFile(filename, []byte("1"), 0644)
if err != nil {
t.Fatal(err)
}
stat := func(name string) (os.FileInfo, error) {
return os.Stat(name)
}
ttl := 1 * time.Second
c := newStatCache(ttl)
// fetch new stat
fi, err := c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// save original FileInfo as a StaticFileInfo so we can reuse it later
// without worrying about the underlying FileInfo changing.
originalFI := &shared.StaticFileInfo{
Named: fi.Name(),
Sized: fi.Size(),
Moded: fi.Mode(),
ModdedTime: fi.ModTime(),
Dir: fi.IsDir(),
}
// update file to size 2
err = os.WriteFile(filename, []byte("12"), 0644)
if err != nil {
t.Fatal(err)
}
// fetch stat again, should still be cached
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// wait for cache to expire and refetch stat, size should reflect new size
time.Sleep(ttl * 2)
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 2 {
t.Errorf("got size %d, want 2", fi.Size())
}
// explicitly set the original FileInfo and make sure it's returned
c.set(dir, []fs.FileInfo{originalFI})
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 1 {
t.Errorf("got size %d, want 1", fi.Size())
}
// invalidate the cache and make sure the new size is returned
c.invalidate()
fi, err = c.getOrFetch(filename, stat)
if err != nil {
t.Fatal(err)
}
if fi.Size() != 2 {
t.Errorf("got size %d, want 2", fi.Size())
}
c.stop()
}

@ -0,0 +1,256 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package webdavfs provides an implementation of webdav.FileSystem backed by
// a gowebdav.Client.
package webdavfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/tailscale/gowebdav"
"github.com/tailscale/xnet/webdav"
"tailscale.com/tailfs/shared"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
const (
// keep requests from taking too long if the server is down or slow to respond
opTimeout = 2 * time.Second // TODO(oxtoacart): tune this
)
type Options struct {
// Logf us a logging function to use for debug and error logging.
Logf logger.Logf
// URL is the base URL of the remote WebDAV server.
URL string
// Transport is the http.Transport to use for connecting to the WebDAV
// server.
Transport http.RoundTripper
// StatRoot, if true, will cause this filesystem to actually stat its own
// root via the remote server. If false, it will use a static directory
// info for the root to avoid a round-trip.
StatRoot bool
// StatCacheTTL, when greater than 0, enables caching of file metadata
StatCacheTTL time.Duration
// Clock, if specified, determines the current time. If not specified, we
// default to time.Now().
Clock tstime.Clock
}
// webdavFS adapts gowebdav.Client to webdav.FileSystem
type webdavFS struct {
logf logger.Logf
transport http.RoundTripper
*gowebdav.Client
now func() time.Time
statRoot bool
statCache *statCache
}
// New creates a new webdav.FileSystem backed by the given gowebdav.Client.
// If cacheTTL is greater than zero, the filesystem will cache results from
// Stat calls for the given duration.
func New(opts Options) webdav.FileSystem {
if opts.Logf == nil {
opts.Logf = log.Printf
}
wfs := &webdavFS{
logf: opts.Logf,
transport: opts.Transport,
Client: gowebdav.New(&gowebdav.Opts{URI: opts.URL, Transport: opts.Transport}),
statRoot: opts.StatRoot,
}
if opts.StatCacheTTL > 0 {
wfs.statCache = newStatCache(opts.StatCacheTTL)
}
if opts.Clock != nil {
wfs.now = opts.Clock.Now
} else {
wfs.now = time.Now
}
return wfs
}
// Mkdir implements webdav.FileSystem.
func (wfs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return translateWebDAVError(wfs.Client.Mkdir(ctxWithTimeout, name, perm))
}
// OpenFile implements webdav.FileSystem.
func (wfs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if hasFlag(flag, os.O_APPEND) {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("mode APPEND not supported"),
}
}
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if hasFlag(flag, os.O_WRONLY) || hasFlag(flag, os.O_RDWR) {
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil && fi.IsDir() {
return nil, &os.PathError{
Op: "open",
Path: name,
Err: errors.New("is a directory"),
}
}
pipeReader, pipeWriter := io.Pipe()
f := &writeOnlyFile{
WriteCloser: pipeWriter,
name: name,
perm: perm,
fs: wfs,
finalError: make(chan error, 1),
}
go func() {
defer pipeReader.Close()
err := wfs.Client.WriteStream(context.Background(), name, pipeReader, perm)
f.finalError <- err
close(f.finalError)
}()
return f, nil
}
// Assume reading
fi, err := wfs.Stat(ctxWithTimeout, name)
if err != nil {
return nil, translateWebDAVError(err)
}
if fi.IsDir() {
return wfs.dirWithChildren(name, fi), nil
}
return &readOnlyFile{
client: wfs.Client,
name: name,
initialFI: fi,
rewindBuffer: make([]byte, 0, MaxRewindBuffer),
}, nil
}
func (wfs *webdavFS) dirWithChildren(name string, fi fs.FileInfo) webdav.File {
return &shared.DirFile{
Info: fi,
LoadChildren: func() ([]fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
dirInfos, err := wfs.Client.ReadDir(ctxWithTimeout, name)
if err != nil {
wfs.logf("encountered error reading children of '%v', returning empty list: %v", name, err)
// We do not return the actual error here because some WebDAV clients
// will take that as an invitation to retry, hanging in the process.
return dirInfos, nil
}
if wfs.statCache != nil {
wfs.statCache.set(name, dirInfos)
}
return dirInfos, nil
},
}
}
// RemoveAll implements webdav.FileSystem.
func (wfs *webdavFS) RemoveAll(ctx context.Context, name string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.RemoveAll(ctxWithTimeout, name)
}
// Rename implements webdav.FileSystem.
func (wfs *webdavFS) Rename(ctx context.Context, oldName, newName string) error {
ctxWithTimeout, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
if wfs.statCache != nil {
wfs.statCache.invalidate()
}
return wfs.Client.Rename(ctxWithTimeout, oldName, newName, false)
}
// Stat implements webdav.FileSystem.
func (wfs *webdavFS) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
if wfs.statCache != nil {
return wfs.statCache.getOrFetch(name, wfs.doStat)
}
return wfs.doStat(name)
}
// Close implements webdav.FileSystem.
func (wfs *webdavFS) Close() error {
if wfs.statCache != nil {
wfs.statCache.stop()
}
tr, ok := wfs.transport.(*http.Transport)
if ok {
tr.CloseIdleConnections()
}
return nil
}
func (wfs *webdavFS) doStat(name string) (fs.FileInfo, error) {
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if !wfs.statRoot && shared.IsRoot(name) {
// use a static directory info for the root
// always use now() as the modified time to bust caches
return shared.ReadOnlyDirInfo(name, wfs.now()), nil
}
fi, err := wfs.Client.Stat(ctxWithTimeout, name)
return fi, translateWebDAVError(err)
}
func translateWebDAVError(err error) error {
if err == nil {
return nil
}
var se gowebdav.StatusError
if errors.As(err, &se) {
if se.Status == http.StatusNotFound {
return os.ErrNotExist
}
}
// Note, we intentionally don't wrap the error because we don't want
// github.com/tailscale/xnet/webdav to try to interpret the underlying
// error.
return fmt.Errorf("unexpected WebDAV error: %v", err)
}
func hasFlag(flags int, flag int) bool {
return (flags & flag) == flag
}

@ -0,0 +1,89 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package webdavfs
import (
"context"
"errors"
"io"
"io/fs"
"os"
"tailscale.com/tailfs/shared"
)
type writeOnlyFile struct {
io.WriteCloser
name string
perm os.FileMode
fs *webdavFS
finalError chan error
}
// Readdir implements webdav.File. As this is a file, this always fails with an
// os.PathError.
func (f *writeOnlyFile) Readdir(count int) ([]fs.FileInfo, error) {
return nil, &os.PathError{
Op: "readdir",
Path: f.name,
Err: errors.New("is a file"), // TODO(oxtoacart): make sure this and below errors match what a regular os.File does
}
}
// Seek implements webdav.File. This always fails with an os.PathError.
func (f *writeOnlyFile) Seek(offset int64, whence int) (int64, error) {
return 0, &os.PathError{
Op: "seek",
Path: f.name,
Err: errors.New("seek not supported"),
}
}
// Stat implements webdav.File.
func (f *writeOnlyFile) Stat() (fs.FileInfo, error) {
fi, err := f.fs.Stat(context.Background(), f.name)
if err != nil {
// use static info for newly created file
now := f.fs.now()
fi = &shared.StaticFileInfo{
Named: f.name,
Sized: 0,
Moded: f.perm,
BirthedTime: now,
ModdedTime: now,
Dir: false,
}
}
return fi, nil
}
// Read implements webdav.File. As this is a write-only file, it always fails
// with an os.PathError.
func (f *writeOnlyFile) Read(p []byte) (int, error) {
return 0, &os.PathError{
Op: "write",
Path: f.name,
Err: errors.New("write-only"),
}
}
// Write implements webdav.File.
func (f *writeOnlyFile) Write(p []byte) (int, error) {
select {
case err := <-f.finalError:
return 0, err
default:
return f.WriteCloser.Write(p)
}
}
// Close implements webdav.File.
func (f *writeOnlyFile) Close() error {
err := f.WriteCloser.Close()
writeErr := <-f.finalError
if writeErr != nil {
return writeErr
}
return err
}

@ -29,6 +29,7 @@ import (
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
"tailscale.com/net/tstun" "tailscale.com/net/tstun"
"tailscale.com/proxymap" "tailscale.com/proxymap"
"tailscale.com/tailfs"
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/magicsock" "tailscale.com/wgengine/magicsock"
@ -47,6 +48,7 @@ type System struct {
Tun SubSystem[*tstun.Wrapper] Tun SubSystem[*tstun.Wrapper]
StateStore SubSystem[ipn.StateStore] StateStore SubSystem[ipn.StateStore]
Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl Netstack SubSystem[NetstackImpl] // actually a *netstack.Impl
TailfsForLocal SubSystem[*tailfs.FileSystemForLocal]
// InitialConfig is initial server config, if any. // InitialConfig is initial server config, if any.
// It is nil if the node is not in declarative mode. // It is nil if the node is not in declarative mode.
@ -98,6 +100,8 @@ func (s *System) Set(v any) {
s.StateStore.Set(v) s.StateStore.Set(v)
case NetstackImpl: case NetstackImpl:
s.Netstack.Set(v) s.Netstack.Set(v)
case *tailfs.FileSystemForLocal:
s.TailfsForLocal.Set(v)
default: default:
panic(fmt.Sprintf("unknown type %T", v)) panic(fmt.Sprintf("unknown type %T", v))
} }

@ -530,7 +530,8 @@ func (s *Server) start() (reterr error) {
closePool.add(s.dialer) closePool.add(s.dialer)
sys.Set(eng) sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), s.dialer, sys.DNSManager.Get(), sys.ProxyMapper()) // TODO(oxtoacart): do we need to support Tailfs on tsnet, and if so, how?
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), s.dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
if err != nil { if err != nil {
return fmt.Errorf("netstack.Create: %w", err) return fmt.Errorf("netstack.Create: %w", err)
} }

@ -38,6 +38,7 @@ import (
_ "tailscale.com/ssh/tailssh" _ "tailscale.com/ssh/tailssh"
_ "tailscale.com/syncs" _ "tailscale.com/syncs"
_ "tailscale.com/tailcfg" _ "tailscale.com/tailcfg"
_ "tailscale.com/tailfs"
_ "tailscale.com/tsd" _ "tailscale.com/tsd"
_ "tailscale.com/tsweb/varz" _ "tailscale.com/tsweb/varz"
_ "tailscale.com/types/flagtype" _ "tailscale.com/types/flagtype"

@ -38,6 +38,7 @@ import (
_ "tailscale.com/ssh/tailssh" _ "tailscale.com/ssh/tailssh"
_ "tailscale.com/syncs" _ "tailscale.com/syncs"
_ "tailscale.com/tailcfg" _ "tailscale.com/tailcfg"
_ "tailscale.com/tailfs"
_ "tailscale.com/tsd" _ "tailscale.com/tsd"
_ "tailscale.com/tsweb/varz" _ "tailscale.com/tsweb/varz"
_ "tailscale.com/types/flagtype" _ "tailscale.com/types/flagtype"

@ -38,6 +38,7 @@ import (
_ "tailscale.com/ssh/tailssh" _ "tailscale.com/ssh/tailssh"
_ "tailscale.com/syncs" _ "tailscale.com/syncs"
_ "tailscale.com/tailcfg" _ "tailscale.com/tailcfg"
_ "tailscale.com/tailfs"
_ "tailscale.com/tsd" _ "tailscale.com/tsd"
_ "tailscale.com/tsweb/varz" _ "tailscale.com/tsweb/varz"
_ "tailscale.com/types/flagtype" _ "tailscale.com/types/flagtype"

@ -38,6 +38,7 @@ import (
_ "tailscale.com/ssh/tailssh" _ "tailscale.com/ssh/tailssh"
_ "tailscale.com/syncs" _ "tailscale.com/syncs"
_ "tailscale.com/tailcfg" _ "tailscale.com/tailcfg"
_ "tailscale.com/tailfs"
_ "tailscale.com/tsd" _ "tailscale.com/tsd"
_ "tailscale.com/tsweb/varz" _ "tailscale.com/tsweb/varz"
_ "tailscale.com/types/flagtype" _ "tailscale.com/types/flagtype"

@ -45,6 +45,7 @@ import (
_ "tailscale.com/safesocket" _ "tailscale.com/safesocket"
_ "tailscale.com/syncs" _ "tailscale.com/syncs"
_ "tailscale.com/tailcfg" _ "tailscale.com/tailcfg"
_ "tailscale.com/tailfs"
_ "tailscale.com/tsd" _ "tailscale.com/tsd"
_ "tailscale.com/tsweb/varz" _ "tailscale.com/tsweb/varz"
_ "tailscale.com/types/flagtype" _ "tailscale.com/types/flagtype"

@ -48,6 +48,7 @@ import (
"tailscale.com/proxymap" "tailscale.com/proxymap"
"tailscale.com/syncs" "tailscale.com/syncs"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/types/ipproto" "tailscale.com/types/ipproto"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
@ -63,8 +64,8 @@ const debugPackets = false
var debugNetstack = envknob.RegisterBool("TS_DEBUG_NETSTACK") var debugNetstack = envknob.RegisterBool("TS_DEBUG_NETSTACK")
var ( var (
magicDNSIP = tsaddr.TailscaleServiceIP() serviceIP = tsaddr.TailscaleServiceIP()
magicDNSIPv6 = tsaddr.TailscaleServiceIPv6() serviceIPv6 = tsaddr.TailscaleServiceIPv6()
) )
func init() { func init() {
@ -120,18 +121,19 @@ type Impl struct {
// It can only be set before calling Start. // It can only be set before calling Start.
ProcessSubnets bool ProcessSubnets bool
ipstack *stack.Stack ipstack *stack.Stack
linkEP *channel.Endpoint linkEP *channel.Endpoint
tundev *tstun.Wrapper tundev *tstun.Wrapper
e wgengine.Engine e wgengine.Engine
pm *proxymap.Mapper pm *proxymap.Mapper
mc *magicsock.Conn mc *magicsock.Conn
logf logger.Logf logf logger.Logf
dialer *tsdial.Dialer dialer *tsdial.Dialer
ctx context.Context // alive until Close ctx context.Context // alive until Close
ctxCancel context.CancelFunc // called on Close ctxCancel context.CancelFunc // called on Close
lb *ipnlocal.LocalBackend // or nil lb *ipnlocal.LocalBackend // or nil
dns *dns.Manager dns *dns.Manager
tailfsForLocal *tailfs.FileSystemForLocal // or nil
peerapiPort4Atomic atomic.Uint32 // uint16 port number for IPv4 peerapi peerapiPort4Atomic atomic.Uint32 // uint16 port number for IPv4 peerapi
peerapiPort6Atomic atomic.Uint32 // uint16 port number for IPv6 peerapi peerapiPort6Atomic atomic.Uint32 // uint16 port number for IPv6 peerapi
@ -159,7 +161,7 @@ const nicID = 1
const maxUDPPacketSize = tstun.MaxPacketSize const maxUDPPacketSize = tstun.MaxPacketSize
// Create creates and populates a new Impl. // Create creates and populates a new Impl.
func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magicsock.Conn, dialer *tsdial.Dialer, dns *dns.Manager, pm *proxymap.Mapper) (*Impl, error) { func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magicsock.Conn, dialer *tsdial.Dialer, dns *dns.Manager, pm *proxymap.Mapper, tailfsForLocal *tailfs.FileSystemForLocal) (*Impl, error) {
if mc == nil { if mc == nil {
return nil, errors.New("nil magicsock.Conn") return nil, errors.New("nil magicsock.Conn")
} }
@ -239,6 +241,7 @@ func Create(logf logger.Logf, tundev *tstun.Wrapper, e wgengine.Engine, mc *magi
dialer: dialer, dialer: dialer,
connsOpenBySubnetIP: make(map[netip.Addr]int), connsOpenBySubnetIP: make(map[netip.Addr]int),
dns: dns, dns: dns,
tailfsForLocal: tailfsForLocal,
} }
ns.ctx, ns.ctxCancel = context.WithCancel(context.Background()) ns.ctx, ns.ctxCancel = context.WithCancel(context.Background())
ns.atomicIsLocalIPFunc.Store(tsaddr.FalseContainsIPFunc()) ns.atomicIsLocalIPFunc.Store(tsaddr.FalseContainsIPFunc())
@ -440,16 +443,16 @@ func (ns *Impl) handleLocalPackets(p *packet.Parsed, t *tstun.Wrapper) filter.Re
return filter.DropSilently return filter.DropSilently
} }
// If it's not traffic to the service IP (i.e. magicDNS) we don't // If it's not traffic to the service IP (e.g. magicDNS or Tailfs) we don't
// care; resume processing. // care; resume processing.
if dst := p.Dst.Addr(); dst != magicDNSIP && dst != magicDNSIPv6 { if dst := p.Dst.Addr(); dst != serviceIP && dst != serviceIPv6 {
return filter.Accept return filter.Accept
} }
// Of traffic to the service IP, we only care about UDP 53, and TCP // Of traffic to the service IP, we only care about UDP 53, and TCP
// on port 80 & 53. // on port 53, 80, and 8080.
switch p.IPProto { switch p.IPProto {
case ipproto.TCP: case ipproto.TCP:
if port := p.Dst.Port(); port != 53 && port != 80 { if port := p.Dst.Port(); port != 53 && port != 80 && port != 8080 {
return filter.Accept return filter.Accept
} }
case ipproto.UDP: case ipproto.UDP:
@ -546,12 +549,12 @@ func (ns *Impl) inject() {
if b := pkt.NetworkHeader().Slice(); len(b) >= 20 { // min ipv4 header if b := pkt.NetworkHeader().Slice(); len(b) >= 20 { // min ipv4 header
switch b[0] >> 4 { // ip proto field switch b[0] >> 4 { // ip proto field
case 4: case 4:
if srcIP := netaddr.IPv4(b[12], b[13], b[14], b[15]); magicDNSIP == srcIP { if srcIP := netaddr.IPv4(b[12], b[13], b[14], b[15]); serviceIP == srcIP {
sendToHost = true sendToHost = true
} }
case 6: case 6:
if len(b) >= 40 { // min ipv6 header if len(b) >= 40 { // min ipv6 header
if srcIP, ok := netip.AddrFromSlice(net.IP(b[8:24])); ok && magicDNSIPv6 == srcIP { if srcIP, ok := netip.AddrFromSlice(net.IP(b[8:24])); ok && serviceIPv6 == srcIP {
sendToHost = true sendToHost = true
} }
} }
@ -916,13 +919,24 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
return gonet.NewTCPConn(&wq, ep) return gonet.NewTCPConn(&wq, ep)
} }
// DNS // Local DNS Service (DNS and WebDAV)
if reqDetails.LocalPort == 53 && (dialIP == magicDNSIP || dialIP == magicDNSIPv6) { hittingServiceIP := dialIP == serviceIP || dialIP == serviceIPv6
hittingDNS := hittingServiceIP && reqDetails.LocalPort == 53
hittingTailfs := hittingServiceIP && ns.tailfsForLocal != nil && reqDetails.LocalPort == 8080
if hittingDNS || hittingTailfs {
c := getConnOrReset() c := getConnOrReset()
if c == nil { if c == nil {
return return
} }
go ns.dns.HandleTCPConn(c, netip.AddrPortFrom(clientRemoteIP, reqDetails.RemotePort)) addrPort := netip.AddrPortFrom(clientRemoteIP, reqDetails.RemotePort)
if hittingDNS {
go ns.dns.HandleTCPConn(c, addrPort)
} else if hittingTailfs {
err := ns.tailfsForLocal.HandleConn(c, net.TCPAddrFromAddrPort(addrPort))
if err != nil {
ns.logf("netstack: tailfs.HandleConn: %v", err)
}
}
return return
} }
@ -1056,7 +1070,7 @@ func (ns *Impl) acceptUDP(r *udp.ForwarderRequest) {
} }
// Handle magicDNS traffic (via UDP) here. // Handle magicDNS traffic (via UDP) here.
if dst := dstAddr.Addr(); dst == magicDNSIP || dst == magicDNSIPv6 { if dst := dstAddr.Addr(); dst == serviceIP || dst == serviceIPv6 {
if dstAddr.Port() != 53 { if dstAddr.Port() != 53 {
ep.Close() ep.Close()
return // Only MagicDNS traffic runs on the service IPs for now. return // Only MagicDNS traffic runs on the service IPs for now.

@ -53,7 +53,7 @@ func TestInjectInboundLeak(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
ns, err := Create(logf, tunWrap, eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) ns, err := Create(logf, tunWrap, eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -102,7 +102,7 @@ func makeNetstack(t *testing.T, config func(*Impl)) *Impl {
t.Cleanup(func() { eng.Close() }) t.Cleanup(func() { eng.Close() })
sys.Set(eng) sys.Set(eng)
ns, err := Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) ns, err := Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

@ -34,6 +34,7 @@ import (
"tailscale.com/net/tstun" "tailscale.com/net/tstun"
"tailscale.com/syncs" "tailscale.com/syncs"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tailfs"
"tailscale.com/tstime/mono" "tailscale.com/tstime/mono"
"tailscale.com/types/dnstype" "tailscale.com/types/dnstype"
"tailscale.com/types/ipproto" "tailscale.com/types/ipproto"
@ -201,6 +202,10 @@ type Config struct {
// SetSubsystem, if non-nil, is called for each new subsystem created, just before a successful return. // SetSubsystem, if non-nil, is called for each new subsystem created, just before a successful return.
SetSubsystem func(any) SetSubsystem func(any)
// EnableTailfs, if true, will cause the engine to expose a Tailfs listener
// at 100.100.100.100:8080
EnableTailfs bool
} }
// NewFakeUserspaceEngine returns a new userspace engine for testing. // NewFakeUserspaceEngine returns a new userspace engine for testing.
@ -446,6 +451,9 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error)
conf.SetSubsystem(conf.Router) conf.SetSubsystem(conf.Router)
conf.SetSubsystem(conf.Dialer) conf.SetSubsystem(conf.Dialer)
conf.SetSubsystem(e.netMon) conf.SetSubsystem(e.netMon)
if conf.EnableTailfs {
conf.SetSubsystem(tailfs.NewFileSystemForLocal(e.logf))
}
} }
e.logf("Engine created.") e.logf("Engine created.")

Loading…
Cancel
Save