cmd/tailscale,ipn:add support for automounting TailFS shares on MacOS

This adds two flags to the "tailscale set" command.

--automount-enabled enables automatically mounting TailFS shares
--automount-path optionally specifies the path at which to automount

If --automount-path is not set, TailFS will be mounted at /Volumes/tailscale.

The mount is owned by whatever user invoked "tailscale set" and has mode
0700 set (read,write,execute only by owning user).

By default, automounting is not enabled.

Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
Percy Wegmann 2024-02-24 12:20:41 -06:00
parent 15b2c674bf
commit 3985947437
No known key found for this signature in database
GPG Key ID: 29D8CDEB4C13D48B
13 changed files with 253 additions and 23 deletions

View File

@ -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+

View File

@ -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 {

View File

@ -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) {

View File

@ -56,6 +56,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
AutomountShares AutomountPrefs
Persist *persist.Persist
}{})

View File

@ -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
}{})

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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 {

12
tailfs/automount.go Normal file
View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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.
}