diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index fb83b65f5..21030ef05 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -114,7 +114,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa 💣 tailscale.com/safesocket from tailscale.com/client/tailscale tailscale.com/syncs from tailscale.com/cmd/derper+ tailscale.com/tailcfg from tailscale.com/client/tailscale+ - tailscale.com/tailfs from tailscale.com/client/tailscale + tailscale.com/tailfs from tailscale.com/client/tailscale+ tailscale.com/tka from tailscale.com/client/tailscale+ W tailscale.com/tsconst from tailscale.com/net/interfaces tailscale.com/tstime from tailscale.com/derp+ @@ -264,7 +264,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa os from crypto/rand+ os/exec from github.com/coreos/go-iptables/iptables+ os/signal from tailscale.com/cmd/derper - W os/user from tailscale.com/util/winutil + DW os/user from tailscale.com/util/winutil+ path from github.com/prometheus/client_golang/prometheus/internal+ path/filepath from crypto/x509+ reflect from crypto/x509+ diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index 02d4f5a06..68e8e1dcc 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -18,6 +18,7 @@ import ( "tailscale.com/net/netutil" "tailscale.com/net/tsaddr" "tailscale.com/safesocket" + "tailscale.com/tailfs" "tailscale.com/types/opt" "tailscale.com/types/views" "tailscale.com/version" @@ -56,6 +57,8 @@ type setArgsT struct { updateCheck bool updateApply bool postureChecking bool + automountEnabled bool + automountPath string } func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { @@ -76,6 +79,12 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version") setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information") setf.BoolVar(&setArgs.runWebClient, "webclient", false, "run a web interface for managing this node, served over Tailscale at port 5252") + automountDisclaimer := "" + if !tailfs.AutomountSupported() { + automountDisclaimer = "(NOT AVAILABLE ON THIS SYSTEM) " + } + setf.BoolVar(&setArgs.automountEnabled, "automount-enabled", false, fmt.Sprintf("%sautomatically mount Tailscale shares", automountDisclaimer)) + setf.StringVar(&setArgs.automountPath, "automount-path", "", fmt.Sprintf(`%spath at which to automount shares, leave blank to default to %q`, automountDisclaimer, tailfs.DefaultAutomountPath())) if safesocket.GOOSUsesPeerCreds(goos) { setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") @@ -124,6 +133,10 @@ func runSet(ctx context.Context, args []string) (retErr error) { Advertise: setArgs.advertiseConnector, }, PostureChecking: setArgs.postureChecking, + AutomountShares: ipn.AutomountPrefs{ + Enabled: setArgs.automountEnabled, + Path: setArgs.automountPath, + }, }, } @@ -151,6 +164,9 @@ func runSet(ctx context.Context, args []string) (retErr error) { if maskedPrefs.IsEmpty() { return flag.ErrHelp } + if maskedPrefs.AutomountSharesSet && !tailfs.AutomountSupported() { + return errors.New("flag automount-enabled is not supported on this system") + } curPrefs, err := localClient.GetPrefs(ctx) if err != nil { diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 3aa66865f..7695389d4 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -722,6 +722,8 @@ func init() { addPrefFlagMapping("auto-update", "AutoUpdate.Apply") addPrefFlagMapping("advertise-connector", "AppConnector") addPrefFlagMapping("posture-checking", "PostureChecking") + addPrefFlagMapping("automount-enabled", "AutomountShares") + addPrefFlagMapping("automount-path", "AutomountShares") } func addPrefFlagMapping(flagName string, prefNames ...string) { diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 40cc44296..8ac5d95d3 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -56,6 +56,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct { AppConnector AppConnectorPrefs PostureChecking bool NetfilterKind string + AutomountShares AutomountPrefs Persist *persist.Persist }{}) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 18436867d..e661c35c6 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -91,6 +91,7 @@ func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpda func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector } func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking } func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind } +func (v PrefsView) AutomountShares() AutomountPrefs { return v.ж.AutomountShares } func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() } // A compilation failure here means this code must be regenerated, with the command at the top of this file. @@ -121,6 +122,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct { AppConnector AppConnectorPrefs PostureChecking bool NetfilterKind string + AutomountShares AutomountPrefs Persist *persist.Persist }{}) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b8aa769a1..5d6175e7c 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1847,6 +1847,8 @@ func (b *LocalBackend) Start(opts ipn.Options) error { // use logout instead. cc.Login(nil, controlclient.LoginDefault) } + + b.tailFSConfigureAutomount(ipn.AutomountPrefs{}, prefs.AutomountShares()) b.stateMachine() return nil } @@ -3261,6 +3263,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn b.authReconfig() } + b.tailFSConfigureAutomount(oldp.AutomountShares(), prefs.AutomountShares()) b.send(ipn.Notify{Prefs: &prefs}) return prefs } diff --git a/ipn/ipnlocal/tailfs.go b/ipn/ipnlocal/tailfs.go index d06599661..d52a3b789 100644 --- a/ipn/ipnlocal/tailfs.go +++ b/ipn/ipnlocal/tailfs.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "regexp" "strings" @@ -278,3 +279,24 @@ func (b *LocalBackend) updateTailFSPeersLocked(nm *netmap.NetworkMap) { } fs.SetRemotes(b.netMap.Domain, tailfsRemotes, &tailFSTransport{b: b}) } + +func (b *LocalBackend) tailFSConfigureAutomount(old, new ipn.AutomountPrefs) { + oldPath := filepath.Clean(old.PathOrDefault()) + settingsChanged := !new.Equals(old) + + if old.Enabled && pathExists(oldPath) && settingsChanged { + b.logf("Unmounting shares from %q", oldPath) + tailfs.UnmountShares(oldPath) + } + + newPath := filepath.Clean(new.PathOrDefault()) + if new.Enabled && !pathExists(newPath) { + b.logf("Mounting shares at %q as %q", newPath, new.AsUser) + tailfs.MountShares(newPath, new.AsUser) + } +} + +func pathExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a1b7da46f..df9809f54 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -1374,6 +1374,15 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if mp.AutomountSharesSet { + // Set AutomountShares user to the connecting username. + var err error + mp.AutomountShares.AsUser, err = h.getUsername() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } var err error prefs, err = h.b.EditPrefs(mp) if err != nil { diff --git a/ipn/prefs.go b/ipn/prefs.go index 7bfbd613f..b03de4430 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -21,6 +21,7 @@ import ( "tailscale.com/net/netaddr" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" + "tailscale.com/tailfs" "tailscale.com/types/opt" "tailscale.com/types/persist" "tailscale.com/types/preftype" @@ -222,6 +223,10 @@ type Prefs struct { // Linux-only. NetfilterKind string + // AutomountShares configures automatic mounting of the TailFS file system + // at a local path. + AutomountShares AutomountPrefs + // The Persist field is named 'Config' in the file for backward // compatibility with earlier versions. // TODO(apenwarr): We should move this out of here, it's not a pref. @@ -259,6 +264,34 @@ type AppConnectorPrefs struct { Advertise bool } +// AutomountPrefs are the settings for automounting TailFS shares. +type AutomountPrefs struct { + // Enabled specifies whether or not automounting is enabled. + Enabled bool + + // The path at which we mount. If blank, we default to an os-specific + // location like /Volumes/tailscale. + Path string + + // AsUser specifies the user who will own the mounted folder. + AsUser string +} + +// PathOrDefault returns the configured Path or the os-specific +// [tailfs.DefaultAutomountPath] if no Path was specified. +func (am AutomountPrefs) PathOrDefault() string { + if am.Path != "" { + return am.Path + } + return tailfs.DefaultAutomountPath() +} + +func (am1 AutomountPrefs) Equals(am2 AutomountPrefs) bool { + return am1.Enabled == am2.Enabled && + am1.Path == am2.Path && + am1.AsUser == am2.AsUser +} + // MaskedPrefs is a Prefs with an associated bitmask of which fields are set. // // Each FooSet field maps to a corresponding Foo field in Prefs. FooSet can be @@ -293,6 +326,7 @@ type MaskedPrefs struct { AppConnectorSet bool `json:",omitempty"` PostureCheckingSet bool `json:",omitempty"` NetfilterKindSet bool `json:",omitempty"` + AutomountSharesSet bool `json:",omitempty"` } type AutoUpdatePrefsMask struct { @@ -498,6 +532,7 @@ func (p *Prefs) pretty(goos string) string { } sb.WriteString(p.AutoUpdate.Pretty()) sb.WriteString(p.AppConnector.Pretty()) + sb.WriteString(p.AutomountShares.Pretty()) if p.Persist != nil { sb.WriteString(p.Persist.Pretty()) } else { @@ -556,7 +591,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool { p.AutoUpdate.Equals(p2.AutoUpdate) && p.AppConnector == p2.AppConnector && p.PostureChecking == p2.PostureChecking && - p.NetfilterKind == p2.NetfilterKind + p.NetfilterKind == p2.NetfilterKind && + p.AutomountShares.Equals(p2.AutomountShares) } func (au AutoUpdatePrefs) Pretty() string { @@ -576,6 +612,16 @@ func (ap AppConnectorPrefs) Pretty() string { return "" } +func (am AutomountPrefs) Pretty() string { + if !am.Enabled { + return "automount=off " + } + if am.Path != "" { + return fmt.Sprintf("automount=%s ", am.Path) + } + return "automount=on " +} + func compareIPNets(a, b []netip.Prefix) bool { if len(a) != len(b) { return false diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 9251bb2bb..3fe4e2fce 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -62,6 +62,7 @@ func TestPrefsEqual(t *testing.T) { "AppConnector", "PostureChecking", "NetfilterKind", + "AutomountShares", "Persist", } if have := fieldsOf(reflect.TypeFor[Prefs]()); !reflect.DeepEqual(have, prefsHandles) { @@ -339,6 +340,26 @@ func TestPrefsEqual(t *testing.T) { &Prefs{NetfilterKind: ""}, false, }, + { + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path", AsUser: "username"}}, + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path", AsUser: "username"}}, + true, + }, + { + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path", AsUser: "username"}}, + &Prefs{AutomountShares: AutomountPrefs{Enabled: false, Path: "path", AsUser: "username"}}, + false, + }, + { + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path", AsUser: "username"}}, + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path2", AsUser: "username"}}, + false, + }, + { + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path", AsUser: "username"}}, + &Prefs{AutomountShares: AutomountPrefs{Enabled: true, Path: "path", AsUser: "username2"}}, + false, + }, } for i, tt := range tests { got := tt.a.Equals(tt.b) @@ -423,22 +444,22 @@ func TestPrefsPretty(t *testing.T) { { Prefs{}, "linux", - "Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}", + "Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=off Persist=nil}", }, { Prefs{}, "windows", - "Prefs{ra=false mesh=false dns=false want=false update=off Persist=nil}", + "Prefs{ra=false mesh=false dns=false want=false update=off automount=off Persist=nil}", }, { Prefs{ShieldsUp: true}, "windows", - "Prefs{ra=false mesh=false dns=false want=false shields=true update=off Persist=nil}", + "Prefs{ra=false mesh=false dns=false want=false shields=true update=off automount=off Persist=nil}", }, { Prefs{AllowSingleHosts: true}, "windows", - "Prefs{ra=false dns=false want=false update=off Persist=nil}", + "Prefs{ra=false dns=false want=false update=off automount=off Persist=nil}", }, { Prefs{ @@ -446,7 +467,7 @@ func TestPrefsPretty(t *testing.T) { AllowSingleHosts: true, }, "windows", - "Prefs{ra=false dns=false want=false notepad=true update=off Persist=nil}", + "Prefs{ra=false dns=false want=false notepad=true update=off automount=off Persist=nil}", }, { Prefs{ @@ -455,7 +476,7 @@ func TestPrefsPretty(t *testing.T) { ForceDaemon: true, // server mode }, "windows", - "Prefs{ra=false dns=false want=true server=true update=off Persist=nil}", + "Prefs{ra=false dns=false want=true server=true update=off automount=off Persist=nil}", }, { Prefs{ @@ -465,14 +486,14 @@ func TestPrefsPretty(t *testing.T) { AdvertiseTags: []string{"tag:foo", "tag:bar"}, }, "darwin", - `Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" update=off Persist=nil}`, + `Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" update=off automount=off Persist=nil}`, }, { Prefs{ Persist: &persist.Persist{}, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n= u=""}}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=off Persist{lm=, o=, n= u=""}}`, }, { Prefs{ @@ -481,21 +502,21 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n=[B1VKl] u=""}}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=off Persist{lm=, o=, n=[B1VKl] u=""}}`, }, { Prefs{ ExitNodeIP: netip.MustParseAddr("1.2.3.4"), }, "linux", - `Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off automount=off Persist=nil}`, }, { Prefs{ ExitNodeID: tailcfg.StableNodeID("myNodeABC"), }, "linux", - `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off automount=off Persist=nil}`, }, { Prefs{ @@ -503,21 +524,21 @@ func TestPrefsPretty(t *testing.T) { ExitNodeAllowLANAccess: true, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off automount=off Persist=nil}`, }, { Prefs{ ExitNodeAllowLANAccess: true, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=off Persist=nil}`, }, { Prefs{ Hostname: "foo", }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" update=off automount=off Persist=nil}`, }, { Prefs{ @@ -527,7 +548,7 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=check Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=check automount=off Persist=nil}`, }, { Prefs{ @@ -537,7 +558,7 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on automount=off Persist=nil}`, }, { Prefs{ @@ -546,7 +567,7 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off appconnector=advertise automount=off Persist=nil}`, }, { Prefs{ @@ -555,21 +576,35 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=off Persist=nil}`, }, { Prefs{ NetfilterKind: "iptables", }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off netfilterKind=iptables update=off automount=off Persist=nil}`, }, { Prefs{ NetfilterKind: "", }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=off Persist=nil}`, + }, + { + Prefs{ + AutomountShares: AutomountPrefs{Enabled: true}, + }, + "linux", + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=on Persist=nil}`, + }, + { + Prefs{ + AutomountShares: AutomountPrefs{Enabled: true, Path: "/some/path"}, + }, + "linux", + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off automount=/some/path Persist=nil}`, }, } for i, tt := range tests { diff --git a/tailfs/automount.go b/tailfs/automount.go new file mode 100644 index 000000000..b03bfd279 --- /dev/null +++ b/tailfs/automount.go @@ -0,0 +1,12 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package tailfs + +import "tailscale.com/version" + +// AutomountSupported reports whether TailFS automounting is supported on this +// system. +func AutomountSupported() bool { + return DefaultAutomountPath() != "" && !version.IsSandboxedMacOS() +} diff --git a/tailfs/automount_darwin.go b/tailfs/automount_darwin.go new file mode 100644 index 000000000..2960ad7cf --- /dev/null +++ b/tailfs/automount_darwin.go @@ -0,0 +1,62 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build darwin + +package tailfs + +import ( + "log" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" +) + +// DefaultAutomountPath returns the default automount path. If blank, that +// means TailFS is disabled on this platform. +func DefaultAutomountPath() string { + return "/Volumes/tailscale" +} + +func MountShares(location string, username string) { + u, err := user.Lookup(username) + if err != nil { + log.Printf("warning: error looking up user %q, won't automount shares: %s", username, err) + return + } + uid, err := strconv.Atoi(u.Uid) + if err != nil { + log.Printf("warning: failed to parse uid %q, won't automount shares: %s", u.Uid, err) + } + gid, err := strconv.Atoi(u.Gid) + if err != nil { + log.Printf("warning: failed to parse gid %q, won't automount shares: %s", u.Gid, err) + } + + location = filepath.Clean(location) + err = os.MkdirAll(location, 0700) + if err != nil { + log.Printf("warning: can't make automount location %q: %s", location, err) + return + } + + err = os.Chown(location, uid, gid) + if err != nil { + log.Printf("warning: failed to chown automount location, won't automount shares: %s", err) + } + + out, err := exec.Command("sudo", "-u", username, "mount", "-t", "webdav", "http://100.100.100.100:8080", location).CombinedOutput() + if err != nil { + log.Printf("warning: can't automount shares at %q: %s", location, out) + } +} + +func UnmountShares(location string) { + location = filepath.Clean(location) + out, err := exec.Command("diskutil", "umount", location).CombinedOutput() + if err != nil { + log.Printf("warning: can't unmount shares from %q: %s", location, out) + } +} diff --git a/tailfs/automount_nondarwin.go b/tailfs/automount_nondarwin.go new file mode 100644 index 000000000..dcd6df397 --- /dev/null +++ b/tailfs/automount_nondarwin.go @@ -0,0 +1,20 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !darwin + +package tailfs + +// DefaultAutomountPath returns the default automount path. If blank, that +// means TailFS is disabled on this platform. +func DefaultAutomountPath() string { + return "" +} + +func MountShares(location string, username string) { + // Do nothing. +} + +func UnmountShares(location string) { + // Do nothing. +}