diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 30b4b1ac9..5393d2df9 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -23,6 +23,7 @@ import ( "tailscale.com/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/types/preftype" @@ -69,6 +70,9 @@ var upFlagSet = (func() *flag.FlagSet { upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\")") upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") + if safesocket.PlatformUsesPeerCreds() { + upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") + } if runtime.GOOS == "linux" { upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes") upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)") @@ -104,6 +108,7 @@ type upArgsT struct { netfilterMode string authKey string hostname string + opUser string } var upArgs upArgsT @@ -212,6 +217,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo prefs.NoSNAT = !upArgs.snat prefs.Hostname = upArgs.hostname prefs.ForceDaemon = upArgs.forceDaemon + prefs.OperatorUser = upArgs.opUser if goos == "linux" { switch upArgs.netfilterMode { @@ -447,6 +453,7 @@ func init() { addPrefFlagMapping("exit-node", "ExitNodeIP", "ExitNodeIP") addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess") addPrefFlagMapping("unattended", "ForceDaemon") + addPrefFlagMapping("operator", "OperatorUser") } func addPrefFlagMapping(flagName string, prefNames ...string) { @@ -475,7 +482,7 @@ func updateMaskedPrefsFromUpFlag(mp *ipn.MaskedPrefs, flagName string) { case "advertise-exit-node": // This pref is a shorthand for advertise-routes. default: - panic("internal error: unhandled flag " + flagName) + panic(fmt.Sprintf("internal error: unhandled flag %q", flagName)) } } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 6fa920c7f..cb920a62d 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "os/exec" + "os/user" "path/filepath" "runtime" "sort" @@ -2176,6 +2177,27 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) { } } +// OperatorUserID returns the current pref's OperatorUser's ID (in +// os/user.User.Uid string form), or the empty string if none. +func (b *LocalBackend) OperatorUserID() string { + b.mu.Lock() + if b.prefs == nil { + b.mu.Unlock() + return "" + } + opUserName := b.prefs.OperatorUser + b.mu.Unlock() + if opUserName == "" { + return "" + } + u, err := user.Lookup(opUserName) + if err != nil { + b.logf("error looking up operator %q uid: %v", opUserName, err) + return "" + } + return u.Uid +} + // TestOnlyPublicKeys returns the current machine and node public // keys. Used in tests only to facilitate automated node authorization // in the test harness. diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index c1faeb141..c7126b72e 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -287,7 +287,7 @@ func (s *server) serveConn(ctx context.Context, c net.Conn, logf logger.Logf) { defer s.removeAndCloseConn(c) logf("[v1] incoming control connection") - if isReadonlyConn(ci, logf) { + if isReadonlyConn(ci, s.b.OperatorUserID(), logf) { ctx = ipn.ReadonlyContextOf(ctx) } @@ -313,7 +313,7 @@ func (s *server) serveConn(ctx context.Context, c net.Conn, logf logger.Logf) { } } -func isReadonlyConn(ci connIdentity, logf logger.Logf) bool { +func isReadonlyConn(ci connIdentity, operatorUID string, logf logger.Logf) bool { if runtime.GOOS == "windows" { // Windows doesn't need/use this mechanism, at least yet. It // has a different last-user-wins auth model. @@ -342,6 +342,10 @@ func isReadonlyConn(ci connIdentity, logf logger.Logf) bool { logf("connection from userid %v; connection from non-root user matching daemon has access", uid) return rw } + if operatorUID != "" && uid == operatorUID { + logf("connection from userid %v; is configured operator", uid) + return rw + } var adminGroupID string switch runtime.GOOS { case "darwin": @@ -435,7 +439,7 @@ func (s *server) localAPIPermissions(ci connIdentity) (read, write bool) { return false, false } if ci.IsUnixSock { - return true, !isReadonlyConn(ci, logger.Discard) + return true, !isReadonlyConn(ci, s.b.OperatorUserID(), logger.Discard) } return false, false } diff --git a/ipn/prefs.go b/ipn/prefs.go index cd3cc2cda..c932dbbcd 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -153,6 +153,10 @@ type Prefs struct { // Tailscale, if at all. NetfilterMode preftype.NetfilterMode + // OperatorUser is the local machine user name who is allowed to + // operate tailscaled without being root or using sudo. + OperatorUser string `json:",omitempty"` + // 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. @@ -183,6 +187,7 @@ type MaskedPrefs struct { AdvertiseRoutesSet bool `json:",omitempty"` NoSNATSet bool `json:",omitempty"` NetfilterModeSet bool `json:",omitempty"` + OperatorUserSet bool `json:",omitempty"` } // ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs @@ -273,6 +278,9 @@ func (p *Prefs) pretty(goos string) string { if p.Hostname != "" { fmt.Fprintf(&sb, "host=%q ", p.Hostname) } + if p.OperatorUser != "" { + fmt.Fprintf(&sb, "op=%q ", p.OperatorUser) + } if p.Persist != nil { sb.WriteString(p.Persist.Pretty()) } else { @@ -311,6 +319,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool { p.ShieldsUp == p2.ShieldsUp && p.NoSNAT == p2.NoSNAT && p.NetfilterMode == p2.NetfilterMode && + p.OperatorUser == p2.OperatorUser && p.Hostname == p2.Hostname && p.OSVersion == p2.OSVersion && p.DeviceModel == p2.DeviceModel && diff --git a/ipn/prefs_clone.go b/ipn/prefs_clone.go index 14e811ade..b5f3a72b9 100644 --- a/ipn/prefs_clone.go +++ b/ipn/prefs_clone.go @@ -51,5 +51,6 @@ var _PrefsNeedsRegeneration = Prefs(struct { AdvertiseRoutes []netaddr.IPPrefix NoSNAT bool NetfilterMode preftype.NetfilterMode + OperatorUser string Persist *persist.Persist }{}) diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index d50c11ad0..935510dbe 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -33,7 +33,28 @@ func fieldsOf(t reflect.Type) (fields []string) { func TestPrefsEqual(t *testing.T) { tstest.PanicOnLog() - prefsHandles := []string{"ControlURL", "RouteAll", "AllowSingleHosts", "ExitNodeID", "ExitNodeIP", "ExitNodeAllowLANAccess", "CorpDNS", "WantRunning", "ShieldsUp", "AdvertiseTags", "Hostname", "OSVersion", "DeviceModel", "NotepadURLs", "ForceDaemon", "AdvertiseRoutes", "NoSNAT", "NetfilterMode", "Persist"} + prefsHandles := []string{ + "ControlURL", + "RouteAll", + "AllowSingleHosts", + "ExitNodeID", + "ExitNodeIP", + "ExitNodeAllowLANAccess", + "CorpDNS", + "WantRunning", + "ShieldsUp", + "AdvertiseTags", + "Hostname", + "OSVersion", + "DeviceModel", + "NotepadURLs", + "ForceDaemon", + "AdvertiseRoutes", + "NoSNAT", + "NetfilterMode", + "OperatorUser", + "Persist", + } if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) { t.Errorf("Prefs.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, prefsHandles)