diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt
index 403785ee5..8762d12d0 100644
--- a/cmd/tailscaled/depaware.txt
+++ b/cmd/tailscaled/depaware.txt
@@ -157,7 +157,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/util/dnsname                                   from tailscale.com/ipn/ipnstate+
   LW    tailscale.com/util/endian                                    from tailscale.com/net/netns+
         tailscale.com/util/groupmember                               from tailscale.com/ipn/ipnserver
-        tailscale.com/util/lineread                                  from tailscale.com/control/controlclient+
+        tailscale.com/util/lineread                                  from tailscale.com/hostinfo+
         tailscale.com/util/osshare                                   from tailscale.com/cmd/tailscaled+
         tailscale.com/util/pidowner                                  from tailscale.com/ipn/ipnserver
         tailscale.com/util/racebuild                                 from tailscale.com/logpolicy
@@ -165,7 +165,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/util/uniq                                      from tailscale.com/wgengine/magicsock
         tailscale.com/util/winutil                                   from tailscale.com/logpolicy+
         tailscale.com/version                                        from tailscale.com/cmd/tailscaled+
-        tailscale.com/version/distro                                 from tailscale.com/control/controlclient+
+        tailscale.com/version/distro                                 from tailscale.com/cmd/tailscaled+
    W    tailscale.com/wf                                             from tailscale.com/cmd/tailscaled
         tailscale.com/wgengine                                       from tailscale.com/cmd/tailscaled+
         tailscale.com/wgengine/filter                                from tailscale.com/control/controlclient+
diff --git a/control/controlclient/controlclient_test.go b/control/controlclient/controlclient_test.go
index b7ae18570..d7a19d498 100644
--- a/control/controlclient/controlclient_test.go
+++ b/control/controlclient/controlclient_test.go
@@ -75,10 +75,3 @@ func TestStatusEqual(t *testing.T) {
 		}
 	}
 }
-
-func TestOSVersion(t *testing.T) {
-	if osVersion == nil {
-		t.Skip("not available for OS")
-	}
-	t.Logf("Got: %#q", osVersion())
-}
diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go
index 766971291..07ff950b4 100644
--- a/control/controlclient/direct.go
+++ b/control/controlclient/direct.go
@@ -20,7 +20,6 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
-	"path/filepath"
 	"reflect"
 	"runtime"
 	"strconv"
@@ -33,6 +32,7 @@ import (
 	"inet.af/netaddr"
 	"tailscale.com/control/controlknobs"
 	"tailscale.com/health"
+	"tailscale.com/hostinfo"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/log/logheap"
 	"tailscale.com/net/dnscache"
@@ -47,9 +47,7 @@ import (
 	"tailscale.com/types/opt"
 	"tailscale.com/types/persist"
 	"tailscale.com/types/wgkey"
-	"tailscale.com/util/dnsname"
 	"tailscale.com/util/systemd"
-	"tailscale.com/version"
 	"tailscale.com/wgengine/monitor"
 )
 
@@ -184,53 +182,13 @@ func NewDirect(opts Options) (*Direct, error) {
 		pinger:                 opts.Pinger,
 	}
 	if opts.Hostinfo == nil {
-		c.SetHostinfo(NewHostinfo())
+		c.SetHostinfo(hostinfo.New())
 	} else {
 		c.SetHostinfo(opts.Hostinfo)
 	}
 	return c, nil
 }
 
-var osVersion func() string // non-nil on some platforms
-
-func NewHostinfo() *tailcfg.Hostinfo {
-	hostname, _ := os.Hostname()
-	hostname = dnsname.FirstLabel(hostname)
-	var osv string
-	if osVersion != nil {
-		osv = osVersion()
-	}
-	return &tailcfg.Hostinfo{
-		IPNVersion: version.Long,
-		Hostname:   hostname,
-		OS:         version.OS(),
-		OSVersion:  osv,
-		Package:    packageType(),
-		GoArch:     runtime.GOARCH,
-	}
-}
-
-func packageType() string {
-	switch runtime.GOOS {
-	case "windows":
-		if _, err := os.Stat(`C:\ProgramData\chocolatey\lib\tailscale`); err == nil {
-			return "choco"
-		}
-	case "darwin":
-		// Using tailscaled or IPNExtension?
-		exe, _ := os.Executable()
-		return filepath.Base(exe)
-	case "linux":
-		// Report whether this is in a snap.
-		// See https://snapcraft.io/docs/environment-variables
-		// We just look at two somewhat arbitrarily.
-		if os.Getenv("SNAP_NAME") != "" && os.Getenv("SNAP") != "" {
-			return "snap"
-		}
-	}
-	return ""
-}
-
 // SetHostinfo clones the provided Hostinfo and remembers it for the
 // next update. It reports whether the Hostinfo has changed.
 func (c *Direct) SetHostinfo(hi *tailcfg.Hostinfo) bool {
diff --git a/control/controlclient/direct_test.go b/control/controlclient/direct_test.go
index 206ba5e49..49ce695d8 100644
--- a/control/controlclient/direct_test.go
+++ b/control/controlclient/direct_test.go
@@ -12,13 +12,14 @@ import (
 	"time"
 
 	"inet.af/netaddr"
+	"tailscale.com/hostinfo"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/tailcfg"
 	"tailscale.com/types/wgkey"
 )
 
 func TestNewDirect(t *testing.T) {
-	hi := NewHostinfo()
+	hi := hostinfo.New()
 	ni := tailcfg.NetInfo{LinkType: "wired"}
 	hi.NetInfo = &ni
 
@@ -60,7 +61,7 @@ func TestNewDirect(t *testing.T) {
 	if changed {
 		t.Errorf("c.SetHostinfo(hi) want false got %v", changed)
 	}
-	hi = NewHostinfo()
+	hi = hostinfo.New()
 	hi.Hostname = "different host name"
 	changed = c.SetHostinfo(hi)
 	if !changed {
@@ -96,20 +97,8 @@ func fakeEndpoints(ports ...uint16) (ret []tailcfg.Endpoint) {
 	return
 }
 
-func TestNewHostinfo(t *testing.T) {
-	hi := NewHostinfo()
-	if hi == nil {
-		t.Fatal("no Hostinfo")
-	}
-	j, err := json.MarshalIndent(hi, "  ", "")
-	if err != nil {
-		t.Fatal(err)
-	}
-	t.Logf("Got: %s", j)
-}
-
 func TestTsmpPing(t *testing.T) {
-	hi := NewHostinfo()
+	hi := hostinfo.New()
 	ni := tailcfg.NetInfo{LinkType: "wired"}
 	hi.NetInfo = &ni
 
diff --git a/control/controlclient/hostinfo_linux.go b/control/controlclient/hostinfo_linux.go
deleted file mode 100644
index 9b1ceb4e2..000000000
--- a/control/controlclient/hostinfo_linux.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build linux && !android
-// +build linux,!android
-
-package controlclient
-
-import (
-	"bytes"
-	"fmt"
-	"io/ioutil"
-	"strings"
-	"syscall"
-
-	"tailscale.com/hostinfo"
-	"tailscale.com/util/lineread"
-	"tailscale.com/version/distro"
-)
-
-func init() {
-	osVersion = osVersionLinux
-}
-
-func osVersionLinux() string {
-	dist := distro.Get()
-	propFile := "/etc/os-release"
-	switch dist {
-	case distro.Synology:
-		propFile = "/etc.defaults/VERSION"
-	case distro.OpenWrt:
-		propFile = "/etc/openwrt_release"
-	}
-
-	m := map[string]string{}
-	lineread.File(propFile, func(line []byte) error {
-		eq := bytes.IndexByte(line, '=')
-		if eq == -1 {
-			return nil
-		}
-		k, v := string(line[:eq]), strings.Trim(string(line[eq+1:]), `"'`)
-		m[k] = v
-		return nil
-	})
-
-	var un syscall.Utsname
-	syscall.Uname(&un)
-
-	var attrBuf strings.Builder
-	attrBuf.WriteString("; kernel=")
-	for _, b := range un.Release {
-		if b == 0 {
-			break
-		}
-		attrBuf.WriteByte(byte(b))
-	}
-	if hostinfo.InContainer() {
-		attrBuf.WriteString("; container")
-	}
-	if env := hostinfo.GetEnvType(); env != "" {
-		fmt.Fprintf(&attrBuf, "; env=%s", env)
-	}
-	attr := attrBuf.String()
-
-	id := m["ID"]
-
-	switch id {
-	case "debian":
-		slurp, _ := ioutil.ReadFile("/etc/debian_version")
-		return fmt.Sprintf("Debian %s (%s)%s", bytes.TrimSpace(slurp), m["VERSION_CODENAME"], attr)
-	case "ubuntu":
-		return fmt.Sprintf("Ubuntu %s%s", m["VERSION"], attr)
-	case "", "centos": // CentOS 6 has no /etc/os-release, so its id is ""
-		if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final)
-			return fmt.Sprintf("%s%s", bytes.TrimSpace(cr), attr)
-		}
-		fallthrough
-	case "fedora", "rhel", "alpine", "nixos":
-		// Their PRETTY_NAME is fine as-is for all versions I tested.
-		fallthrough
-	default:
-		if v := m["PRETTY_NAME"]; v != "" {
-			return fmt.Sprintf("%s%s", v, attr)
-		}
-	}
-	switch dist {
-	case distro.Synology:
-		return fmt.Sprintf("Synology %s%s", m["productversion"], attr)
-	case distro.OpenWrt:
-		return fmt.Sprintf("OpenWrt %s%s", m["DISTRIB_RELEASE"], attr)
-	}
-	return fmt.Sprintf("Other%s", attr)
-}
diff --git a/control/controlclient/hostinfo_windows.go b/control/controlclient/hostinfo_windows.go
deleted file mode 100644
index 07a5d4006..000000000
--- a/control/controlclient/hostinfo_windows.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package controlclient
-
-import (
-	"os/exec"
-	"strings"
-	"sync/atomic"
-	"syscall"
-)
-
-func init() {
-	osVersion = osVersionWindows
-}
-
-var winVerCache atomic.Value // of string
-
-func osVersionWindows() string {
-	if s, ok := winVerCache.Load().(string); ok {
-		return s
-	}
-	cmd := exec.Command("cmd", "/c", "ver")
-	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
-	out, _ := cmd.Output() // "\nMicrosoft Windows [Version 10.0.19041.388]\n\n"
-	s := strings.TrimSpace(string(out))
-	s = strings.TrimPrefix(s, "Microsoft Windows [")
-	s = strings.TrimSuffix(s, "]")
-
-	// "Version 10.x.y.z", with "Version" localized. Keep only stuff after the space.
-	if sp := strings.Index(s, " "); sp != -1 {
-		s = s[sp+1:]
-	}
-	if s != "" {
-		winVerCache.Store(s)
-	}
-	return s // "10.0.19041.388", ideally
-}
diff --git a/hostinfo/hostinfo.go b/hostinfo/hostinfo.go
index 794b383af..328ec8c2d 100644
--- a/hostinfo/hostinfo.go
+++ b/hostinfo/hostinfo.go
@@ -4,20 +4,64 @@
 
 // Package hostinfo answers questions about the host environment that Tailscale is
 // running on.
-//
-// TODO(bradfitz): move more of control/controlclient/hostinfo_* into this package.
 package hostinfo
 
 import (
 	"io"
 	"os"
+	"path/filepath"
 	"runtime"
 	"sync/atomic"
 
 	"go4.org/mem"
+	"tailscale.com/tailcfg"
+	"tailscale.com/util/dnsname"
 	"tailscale.com/util/lineread"
+	"tailscale.com/version"
 )
 
+var osVersion func() string // non-nil on some platforms
+
+// New returns a partially populated Hostinfo for the current host.
+func New() *tailcfg.Hostinfo {
+	hostname, _ := os.Hostname()
+	hostname = dnsname.FirstLabel(hostname)
+	var osv string
+	if osVersion != nil {
+		osv = osVersion()
+	}
+	return &tailcfg.Hostinfo{
+		IPNVersion:  version.Long,
+		Hostname:    hostname,
+		OS:          version.OS(),
+		OSVersion:   osv,
+		Package:     packageType(),
+		GoArch:      runtime.GOARCH,
+		DeviceModel: deviceModel(),
+	}
+}
+
+func packageType() string {
+	switch runtime.GOOS {
+	case "windows":
+		if _, err := os.Stat(`C:\ProgramData\chocolatey\lib\tailscale`); err == nil {
+			return "choco"
+		}
+	case "darwin":
+		// Using tailscaled or IPNExtension?
+		exe, _ := os.Executable()
+		return filepath.Base(exe)
+	case "linux":
+		// Report whether this is in a snap.
+		// See https://snapcraft.io/docs/environment-variables
+		// We just look at two somewhat arbitrarily.
+		if os.Getenv("SNAP_NAME") != "" && os.Getenv("SNAP") != "" {
+			return "snap"
+		}
+	}
+	return ""
+}
+
 // EnvType represents a known environment type.
 // The empty string, the default, means unknown.
 type EnvType string
@@ -42,6 +86,16 @@ func GetEnvType() EnvType {
 	return e
 }
 
+var deviceModelAtomic atomic.Value // of string
+
+// SetDeviceModel sets the device model for use in Hostinfo updates.
+func SetDeviceModel(model string) { deviceModelAtomic.Store(model) }
+
+func deviceModel() string {
+	s, _ := deviceModelAtomic.Load().(string)
+	return s
+}
+
 func getEnvType() EnvType {
 	if inKnative() {
 		return KNative
@@ -64,8 +118,8 @@ func getEnvType() EnvType {
 	return ""
 }
 
-// InContainer reports whether we're running in a container.
-func InContainer() bool {
+// inContainer reports whether we're running in a container.
+func inContainer() bool {
 	if runtime.GOOS != "linux" {
 		return false
 	}
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index 9d54d9a6f..046744404 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -29,6 +29,7 @@ import (
 	"tailscale.com/client/tailscale/apitype"
 	"tailscale.com/control/controlclient"
 	"tailscale.com/health"
+	"tailscale.com/hostinfo"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/ipn/policy"
@@ -730,7 +731,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
 		return nil
 	}
 
-	hostinfo := controlclient.NewHostinfo()
+	hostinfo := hostinfo.New()
 	hostinfo.BackendLogID = b.backendLogID
 	hostinfo.FrontendLogID = opts.FrontendLogID
 
diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go
index a02761719..dfc4ba21c 100644
--- a/tailcfg/tailcfg.go
+++ b/tailcfg/tailcfg.go
@@ -407,7 +407,7 @@ type Hostinfo struct {
 	OS            string             // operating system the client runs on (a version.OS value)
 	OSVersion     string             `json:",omitempty"` // operating system version, with optional distro prefix ("Debian 10.4", "Windows 10 Pro 10.0.19041")
 	Package       string             `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown)
-	DeviceModel   string             `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone 11 Pro")
+	DeviceModel   string             `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3")
 	Hostname      string             // name of the host the client runs on
 	ShieldsUp     bool               `json:",omitempty"` // indicates whether the host is blocking incoming connections
 	ShareeNode    bool               `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user