diff --git a/cmd/tailscale/cli/down.go b/cmd/tailscale/cli/down.go index 5ea1eb6f1..7e53c0cf2 100644 --- a/cmd/tailscale/cli/down.go +++ b/cmd/tailscale/cli/down.go @@ -56,7 +56,6 @@ func runDown(ctx context.Context, args []string) error { } return } - log.Printf("Notify: %#v", n) }) bc.RequestStatus() diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 409571128..08b326913 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -206,6 +206,8 @@ func runUp(ctx context.Context, args []string) error { prefs.AdvertiseTags = tags prefs.NoSNAT = !upArgs.snat prefs.Hostname = upArgs.hostname + prefs.ForceDaemon = (runtime.GOOS == "windows") + if runtime.GOOS == "linux" { switch upArgs.netfilterMode { case "on": @@ -229,6 +231,7 @@ func runUp(ctx context.Context, args []string) error { startLoginInteractive := func() { loginOnce.Do(func() { bc.StartLoginInteractive() }) } bc.SetPrefs(prefs) + opts := ipn.Options{ StateKey: ipn.GlobalDaemonStateKey, AuthKey: upArgs.authKey, @@ -258,6 +261,22 @@ func runUp(ctx context.Context, args []string) error { } }, } + + // On Windows, we still run in mostly the "legacy" way that + // predated the server's StateStore. That is, we send an empty + // StateKey and send the prefs directly. Although the Windows + // supports server mode, though, the transition to StateStore + // is only half complete. Only server mode uses it, and the + // Windows service (~tailscaled) is the one that computes the + // StateKey based on the connection idenity. So for now, just + // do as the Windows GUI's always done: + if runtime.GOOS == "windows" { + // The Windows service will set this as needed based + // on our connection's identity. + opts.StateKey = "" + opts.Prefs = prefs + } + // We still have to Start right now because it's the only way to // set up notifications and whatnot. This causes a bunch of churn // every time the CLI touches anything. diff --git a/ipn/backend.go b/ipn/backend.go index 06eed4501..fe591a0ab 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -85,7 +85,9 @@ type Notify struct { // // * the macOS/iOS GUI apps set it to "ipn-go-bridge" // * the Android app sets it to "ipn-android" -// * on Windows, it's always the the empty string +// * on Windows, it's the empty string (in client mode) or, via +// LocalBackend.userID, a string like "user-$USER_ID" (used in +// server mode). // * on Linux/etc, it's always "_daemon" (ipn.GlobalDaemonStateKey) type StateKey string diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 0fa8a5f0e..4e00e6a01 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -18,6 +18,7 @@ import ( "os/signal" "os/user" "runtime" + "strings" "sync" "syscall" "time" @@ -68,6 +69,12 @@ type Options struct { // its existing state, and accepts new frontend connections. If // false, the server dumps its state and becomes idle. // + // This is effectively whether the platform is in "server + // mode" by default. On Linux, it's true; on Windows, it's + // false. But on some platforms (currently only Windows), the + // "server mode" can be overridden at runtime with a change in + // Prefs.ForceDaemon/WantRunning. + // // To support CLI connections (notably, "tailscale status"), // the actual definition of "disconnect" is when the // connection count transitions from 1 to 0. @@ -81,15 +88,23 @@ type Options struct { // server is an IPN backend and its set of 0 or more active connections // talking to an IPN backend. type server struct { - resetOnZero bool // call bs.Reset on transition from 1->0 connections - b *ipn.LocalBackend + b *ipn.LocalBackend + logf logger.Logf + // resetOnZero is whether to call bs.Reset on transition from + // 1->0 connections. That is, this is whether the backend is + // being run in "client mode" that requires an active GUI + // connection (such as on Windows by default). Even if this + // is true, the ForceDaemon pref can override this. + resetOnZero bool bsMu sync.Mutex // lock order: bsMu, then mu bs *ipn.BackendServer - mu sync.Mutex - allClients map[net.Conn]connIdentity // HTTP or IPN - clients map[net.Conn]bool // subset of allClients; only IPN protocol + mu sync.Mutex + serverModeUser *user.User // or nil if not in server mode + lastUserID string // tracks last userid; on change, Reset state for paranoia + allClients map[net.Conn]connIdentity // HTTP or IPN + clients map[net.Conn]bool // subset of allClients; only IPN protocol } // connIdentity represents the owner of a localhost TCP connection. @@ -168,6 +183,9 @@ func (s *server) serveConn(ctx context.Context, c net.Conn, logf logger.Logf) { return } + // Tell the LocalBackend about the identity we're now running as. + s.b.SetCurrentUserID(ci.UserID) + if isHTTPReq { httpServer := http.Server{ // Localhost connections are cheap; so only do @@ -214,6 +232,18 @@ func (s *server) addConn(c net.Conn, isHTTP bool) (ci connIdentity, err error) { return } + // If the connected user changes, reset the backend server state to make + // sure node keys don't leak between users. + var doReset bool + defer func() { + if doReset { + s.logf("identity changed; resetting server") + s.bsMu.Lock() + s.bs.Reset() + s.bsMu.Unlock() + } + }() + s.mu.Lock() defer s.mu.Unlock() @@ -236,12 +266,22 @@ func (s *server) addConn(c net.Conn, isHTTP bool) (ci connIdentity, err error) { return ci, fmt.Errorf("Tailscale already in use by %s, pid %d", active.User.Username, active.Pid) } } + if su := s.serverModeUser; su != nil && ci.UserID != su.Uid { + //lint:ignore ST1005 we want to capitalize Tailscale here + return ci, fmt.Errorf("Tailscale running in server mode as %s. Access denied.", su.Username) + } if !isHTTP { s.clients[c] = true } s.allClients[c] = ci + if s.lastUserID != ci.UserID { + if s.lastUserID != "" { + doReset = true + } + s.lastUserID = ci.UserID + } return ci, nil } @@ -253,9 +293,14 @@ func (s *server) removeAndCloseConn(c net.Conn) { s.mu.Unlock() if remain == 0 && s.resetOnZero { - s.bsMu.Lock() - s.bs.Reset() - s.bsMu.Unlock() + if s.b.RunningAndDaemonForced() { + s.logf("client disconnected; staying alive in server mode") + } else { + s.logf("client disconnected; stopping server") + s.bsMu.Lock() + s.bs.Reset() + s.bsMu.Unlock() + } } c.Close() } @@ -270,9 +315,48 @@ func (s *server) stopAll() { s.clients = nil } +// setServerModeUserLocked is called when we're in server mode but our s.serverModeUser is nil. +// +// s.mu must be held +func (s *server) setServerModeUserLocked() { + var ci connIdentity + var ok bool + for _, ci = range s.allClients { + ok = true + break + } + if !ok { + s.logf("ipnserver: [unexpected] now in server mode, but no connected client") + return + } + if ci.Unknown { + return + } + if ci.User != nil { + s.logf("ipnserver: now in server mode; user=%v", ci.User.Username) + s.serverModeUser = ci.User + } else { + s.logf("ipnserver: [unexpected] now in server mode, but nil User") + } +} + func (s *server) writeToClients(b []byte) { + inServerMode := s.b.RunningAndDaemonForced() + s.mu.Lock() defer s.mu.Unlock() + + if inServerMode { + if s.serverModeUser == nil { + s.setServerModeUserLocked() + } + } else { + if s.serverModeUser != nil { + s.logf("ipnserver: no longer in server mode") + s.serverModeUser = nil + } + } + for c := range s.clients { ipn.WriteMsg(c, b) } @@ -290,6 +374,7 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( } server := &server{ + logf: logf, resetOnZero: !opts.SurviveDisconnects, } @@ -306,12 +391,11 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( logf("Listening on %v", listen.Addr()) bo := backoff.NewBackoff("ipnserver", logf, 30*time.Second) - var unservedConn net.Conn // if non-nil, accepted, but hasn't served yet eng, err := getEngine() if err != nil { - logf("Initial getEngine call: %v", err) + logf("ipnserver: initial getEngine call: %v", err) for i := 1; ctx.Err() == nil; i++ { c, err := listen.Accept() if err != nil { @@ -319,14 +403,14 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( bo.BackOff(ctx, err) continue } - logf("%d: trying getEngine again...", i) + logf("ipnserver: try%d: trying getEngine again...", i) eng, err = getEngine() if err == nil { logf("%d: GetEngine worked; exiting failure loop", i) unservedConn = c break } - logf("%d: getEngine failed again: %v", i, err) + logf("ipnserver%d: getEngine failed again: %v", i, err) errMsg := err.Error() go func() { defer c.Close() @@ -347,6 +431,24 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() ( if err != nil { return fmt.Errorf("ipn.NewFileStore(%q): %v", opts.StatePath, err) } + if opts.AutostartStateKey == "" { + autoStartKey, err := store.ReadState(ipn.ServerModeStartKey) + if err != nil && err != ipn.ErrStateNotExist { + return fmt.Errorf("calling ReadState on %s: %w", opts.StatePath, err) + } + key := string(autoStartKey) + if strings.HasPrefix(key, "user-") { + uid := strings.TrimPrefix(key, "user-") + u, err := user.LookupId(uid) + if err != nil { + logf("ipnserver: found server mode auto-start key %q; failed to load user: %v", key, err) + } else { + logf("ipnserver: found server mode auto-start key %q (user %s)", key, u.Username) + server.serverModeUser = u + } + opts.AutostartStateKey = ipn.StateKey(key) + } + } } else { store = &ipn.MemoryStore{} } diff --git a/ipn/local.go b/ipn/local.go index b096a5701..90f3001b2 100644 --- a/ipn/local.go +++ b/ipn/local.go @@ -66,7 +66,8 @@ type LocalBackend struct { mu sync.Mutex notify func(Notify) c *controlclient.Client - stateKey StateKey + stateKey StateKey // computed in part from user-provided value + userID string // current controlling user ID (for Windows, primarily) prefs *Prefs machinePrivKey wgcfg.PrivateKey state State @@ -786,6 +787,15 @@ func (b *LocalBackend) State() State { return b.state } +// RunningAndDaemonForced reports whether the backend is currently +// running and the preferences say that Tailscale should run in +// "server mode" (ForceDaemon). +func (b *LocalBackend) RunningAndDaemonForced() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.state == Running && b.prefs != nil && b.prefs.ForceDaemon +} + // getEngineStatus returns a copy of b.engineStatus. // // TODO(bradfitz): remove this and use Status() throughout. @@ -899,6 +909,12 @@ func (b *LocalBackend) shieldsAreUp() bool { return b.prefs.ShieldsUp } +func (b *LocalBackend) SetCurrentUserID(uid string) { + b.mu.Lock() + b.userID = uid + b.mu.Unlock() +} + func (b *LocalBackend) SetWantRunning(wantRunning bool) { b.mu.Lock() new := b.prefs.Clone() @@ -935,6 +951,7 @@ func (b *LocalBackend) SetPrefs(new *Prefs) { applyPrefsToHostinfo(newHi, new) b.hostinfo = newHi hostInfoChanged := !oldHi.Equal(newHi) + userID := b.userID b.mu.Unlock() @@ -943,6 +960,26 @@ func (b *LocalBackend) SetPrefs(new *Prefs) { b.logf("Failed to save new controlclient state: %v", err) } } + if userID != "" { // e.g. on Windows + if new.ForceDaemon { + stateKey := StateKey("user-" + userID) + if err := b.store.WriteState(ServerModeStartKey, []byte(stateKey)); err != nil { + b.logf("WriteState error: %v", err) + } + // It's important we do this here too, even if it looks + // redundant with the one in the 'if stateKey != ""' + // check block above. That one won't fire in the case + // where the Windows client started up in client mode. + // This happens when we transition into server mode: + if err := b.store.WriteState(stateKey, new.ToBytes()); err != nil { + b.logf("WriteState error: %v", err) + } + } else { + if err := b.store.WriteState(ServerModeStartKey, nil); err != nil { + b.logf("WriteState error: %v", err) + } + } + } // [GRINDER STATS LINE] - please don't remove (used for log parsing) b.logf("SetPrefs: %v", new.Pretty()) diff --git a/ipn/prefs.go b/ipn/prefs.go index e3c306aad..115ae1f58 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -11,6 +11,8 @@ import ( "log" "os" "path/filepath" + "runtime" + "strings" "github.com/tailscale/wireguard-go/wgcfg" "tailscale.com/atomicfile" @@ -22,40 +24,51 @@ import ( type Prefs struct { // ControlURL is the URL of the control server to use. ControlURL string + // RouteAll specifies whether to accept subnet and default routes // advertised by other nodes on the Tailscale network. RouteAll bool + // AllowSingleHosts specifies whether to install routes for each // node IP on the tailscale network, in addition to a route for // the whole network. + // This corresponds to the "tailscale up --host-routes" value, + // which defaults to true. // // TODO(danderson): why do we have this? It dumps a lot of stuff // into the routing table, and a single network route _should_ be // all that we need. But when I turn this off in my tailscaled, // packets stop flowing. What's up with that? AllowSingleHosts bool + // CorpDNS specifies whether to install the Tailscale network's // DNS configuration, if it exists. CorpDNS bool + // WantRunning indicates whether networking should be active on // this node. WantRunning bool + // ShieldsUp indicates whether to block all incoming connections, // regardless of the control-provided packet filter. If false, we // use the packet filter as provided. If true, we block incoming // connections. ShieldsUp bool + // AdvertiseTags specifies groups that this node wants to join, for // purposes of ACL enforcement. These can be referenced from the ACL // security policy. Note that advertising a tag doesn't guarantee that // the control server will allow you to take on the rights for that // tag. AdvertiseTags []string + // Hostname is the hostname to use for identifying the node. If // not set, os.Hostname is used. Hostname string + // OSVersion overrides tailcfg.Hostinfo's OSVersion. OSVersion string + // DeviceModel overrides tailcfg.Hostinfo's DeviceModel. DeviceModel string @@ -67,12 +80,25 @@ type Prefs struct { // users narrow it down a bit. NotepadURLs bool + // ForceDaemon specifies whether a platform that normally + // operates in "client mode" (that is, requires an active user + // logged in with the GUI app running) should keep running after the + // GUI ends and/or the user logs out. + // + // The only current applicable platform is Windows. This + // forced Windows to go into "server mode" where Tailscale is + // running even with no users logged in. This might also be + // used for macOS in the future. This setting has no effect + // for Linux/etc, which always operate in daemon mode. + ForceDaemon bool `json:"ForceDaemon,omitempty"` + // The following block of options only have an effect on Linux. // AdvertiseRoutes specifies CIDR prefixes to advertise into the // Tailscale network as reachable through the current // node. AdvertiseRoutes []wgcfg.CIDR + // NoSNAT specifies whether to source NAT traffic going to // destinations in AdvertiseRoutes. The default is to apply source // NAT, which makes the traffic appear to come from the router @@ -84,6 +110,7 @@ type Prefs struct { // // Linux-only. NoSNAT bool + // NetfilterMode specifies how much to manage netfilter rules for // Tailscale, if at all. NetfilterMode router.NetfilterMode @@ -99,16 +126,40 @@ type Prefs struct { // IsEmpty reports whether p is nil or pointing to a Prefs zero value. func (p *Prefs) IsEmpty() bool { return p == nil || p.Equals(&Prefs{}) } -func (p *Prefs) Pretty() string { - var pp string +func (p *Prefs) Pretty() string { return p.pretty(runtime.GOOS) } +func (p *Prefs) pretty(goos string) string { + var sb strings.Builder + sb.WriteString("Prefs{") + fmt.Fprintf(&sb, "ra=%v ", p.RouteAll) + if !p.AllowSingleHosts { + sb.WriteString("mesh=false ") + } + fmt.Fprintf(&sb, "dns=%v want=%v ", p.CorpDNS, p.WantRunning) + if p.ForceDaemon { + sb.WriteString("server=true ") + } + if p.NotepadURLs { + sb.WriteString("notepad=true ") + } + if p.ShieldsUp { + sb.WriteString("shields=true ") + } + if len(p.AdvertiseRoutes) > 0 || goos == "linux" { + fmt.Fprintf(&sb, "routes=%v ", p.AdvertiseRoutes) + } + if len(p.AdvertiseRoutes) > 0 || p.NoSNAT { + fmt.Fprintf(&sb, "snat=%v ", !p.NoSNAT) + } + if goos == "linux" { + fmt.Fprintf(&sb, "nf=%v ", p.NetfilterMode) + } if p.Persist != nil { - pp = p.Persist.Pretty() + sb.WriteString(p.Persist.Pretty()) } else { - pp = "Persist=nil" + sb.WriteString("Persist=nil") } - return fmt.Sprintf("Prefs{ra=%v mesh=%v dns=%v want=%v notepad=%v shields=%v routes=%v snat=%v nf=%v %v}", - p.RouteAll, p.AllowSingleHosts, p.CorpDNS, p.WantRunning, - p.NotepadURLs, p.ShieldsUp, p.AdvertiseRoutes, !p.NoSNAT, p.NetfilterMode, pp) + sb.WriteString("}") + return sb.String() } func (p *Prefs) ToBytes() []byte { @@ -140,6 +191,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool { p.Hostname == p2.Hostname && p.OSVersion == p2.OSVersion && p.DeviceModel == p2.DeviceModel && + p.ForceDaemon == p2.ForceDaemon && compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) && compareStrings(p.AdvertiseTags, p2.AdvertiseTags) && p.Persist.Equals(p2.Persist) diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index a11bd7531..767686cdf 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -24,7 +24,7 @@ func fieldsOf(t reflect.Type) (fields []string) { func TestPrefsEqual(t *testing.T) { tstest.PanicOnLog() - prefsHandles := []string{"ControlURL", "RouteAll", "AllowSingleHosts", "CorpDNS", "WantRunning", "ShieldsUp", "AdvertiseTags", "Hostname", "OSVersion", "DeviceModel", "NotepadURLs", "AdvertiseRoutes", "NoSNAT", "NetfilterMode", "Persist"} + prefsHandles := []string{"ControlURL", "RouteAll", "AllowSingleHosts", "CorpDNS", "WantRunning", "ShieldsUp", "AdvertiseTags", "Hostname", "OSVersion", "DeviceModel", "NotepadURLs", "ForceDaemon", "AdvertiseRoutes", "NoSNAT", "NetfilterMode", "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) @@ -278,3 +278,55 @@ func TestPrefsPersist(t *testing.T) { } checkPrefs(t, p) } + +func TestPrefsPretty(t *testing.T) { + tests := []struct { + p Prefs + os string + want string + }{ + { + Prefs{}, + "linux", + "Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}", + }, + { + Prefs{}, + "windows", + "Prefs{ra=false mesh=false dns=false want=false Persist=nil}", + }, + { + Prefs{ShieldsUp: true}, + "windows", + "Prefs{ra=false mesh=false dns=false want=false shields=true Persist=nil}", + }, + { + Prefs{AllowSingleHosts: true}, + "windows", + "Prefs{ra=false dns=false want=false Persist=nil}", + }, + { + Prefs{ + NotepadURLs: true, + AllowSingleHosts: true, + }, + "windows", + "Prefs{ra=false dns=false want=false notepad=true Persist=nil}", + }, + { + Prefs{ + AllowSingleHosts: true, + WantRunning: true, + ForceDaemon: true, // server mode + }, + "windows", + "Prefs{ra=false dns=false want=true server=true Persist=nil}", + }, + } + for i, tt := range tests { + got := tt.p.pretty(tt.os) + if got != tt.want { + t.Errorf("%d. wrong String:\n got: %s\nwant: %s\n", i, got, tt.want) + } + } +} diff --git a/ipn/store.go b/ipn/store.go index 523cb8ca0..d586f5729 100644 --- a/ipn/store.go +++ b/ipn/store.go @@ -5,6 +5,7 @@ package ipn import ( + "bytes" "encoding/json" "errors" "fmt" @@ -33,6 +34,15 @@ const ( // node-global state. To keep open the option of having per-user state // later, the global state key doesn't look like a username. GlobalDaemonStateKey = StateKey("_daemon") + + // ServerModeStartKey's value, if non-empty, is the value of a + // StateKey containing the prefs to start with which to start the + // server. + // + // For example, the value might be "user-1234", meaning the + // the server should start with the Prefs JSON loaded from + // StateKey "user-1234". + ServerModeStartKey = StateKey("server-mode-start-key") ) // StateStore persists state, and produces it back on request. @@ -132,6 +142,9 @@ func (s *FileStore) ReadState(id StateKey) ([]byte, error) { func (s *FileStore) WriteState(id StateKey, bs []byte) error { s.mu.Lock() defer s.mu.Unlock() + if bytes.Equal(s.cache[id], bs) { + return nil + } s.cache[id] = append([]byte(nil), bs...) bs, err := json.MarshalIndent(s.cache, "", " ") if err != nil {