hostinfo, tailcfg: split Hostinfo.OSVersion into separate fields

Stop jamming everything into one string.

Fixes #5578

Change-Id: I7dec8d6c073bddc7dc5f653e3baf2b4bf6b68378
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2022-09-11 10:57:18 -07:00 committed by Brad Fitzpatrick
parent 708b7bff3d
commit d5e7e3093d
9 changed files with 249 additions and 118 deletions

View File

@ -12,6 +12,7 @@
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -31,25 +32,68 @@ func New() *tailcfg.Hostinfo {
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
hostname = dnsname.FirstLabel(hostname) hostname = dnsname.FirstLabel(hostname)
return &tailcfg.Hostinfo{ return &tailcfg.Hostinfo{
IPNVersion: version.Long, IPNVersion: version.Long,
Hostname: hostname, Hostname: hostname,
OS: version.OS(), OS: version.OS(),
OSVersion: GetOSVersion(), OSVersion: GetOSVersion(),
Desktop: desktop(), Container: lazyInContainer.Get(),
Package: packageTypeCached(), Distro: condCall(distroName),
GoArch: runtime.GOARCH, DistroVersion: condCall(distroVersion),
GoVersion: runtime.Version(), DistroCodeName: condCall(distroCodeName),
DeviceModel: deviceModel(), Env: string(GetEnvType()),
Cloud: string(cloudenv.Get()), Desktop: desktop(),
Package: packageTypeCached(),
GoArch: runtime.GOARCH,
GoVersion: runtime.Version(),
DeviceModel: deviceModel(),
Cloud: string(cloudenv.Get()),
} }
} }
// non-nil on some platforms // non-nil on some platforms
var ( var (
osVersion func() string osVersion func() string
packageType func() string packageType func() string
distroName func() string
distroVersion func() string
distroCodeName func() string
) )
func condCall[T any](fn func() T) T {
var zero T
if fn == nil {
return zero
}
return fn()
}
var (
lazyInContainer = &lazyAtomicValue[opt.Bool]{f: ptrTo(inContainer)}
)
func ptrTo[T any](v T) *T { return &v }
type lazyAtomicValue[T any] struct {
// f is a pointer to a fill function. If it's nil or points
// to nil, then Get returns the zero value for T.
f *func() T
once sync.Once
v T
}
func (v *lazyAtomicValue[T]) Get() T {
v.once.Do(v.fill)
return v.v
}
func (v *lazyAtomicValue[T]) fill() {
if v.f == nil || *v.f == nil {
return
}
v.v = (*v.f)()
}
// GetOSVersion returns the OSVersion of current host if available. // GetOSVersion returns the OSVersion of current host if available.
func GetOSVersion() string { func GetOSVersion() string {
if s, _ := osVersionAtomic.Load().(string); s != "" { if s, _ := osVersionAtomic.Load().(string); s != "" {
@ -179,22 +223,23 @@ func getEnvType() EnvType {
} }
// inContainer reports whether we're running in a container. // inContainer reports whether we're running in a container.
func inContainer() bool { func inContainer() opt.Bool {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
return false return ""
} }
var ret bool var ret opt.Bool
ret.Set(false)
lineread.File("/proc/1/cgroup", func(line []byte) error { lineread.File("/proc/1/cgroup", func(line []byte) error {
if mem.Contains(mem.B(line), mem.S("/docker/")) || if mem.Contains(mem.B(line), mem.S("/docker/")) ||
mem.Contains(mem.B(line), mem.S("/lxc/")) { mem.Contains(mem.B(line), mem.S("/lxc/")) {
ret = true ret.Set(true)
return io.EOF // arbitrary non-nil error to stop loop return io.EOF // arbitrary non-nil error to stop loop
} }
return nil return nil
}) })
lineread.File("/proc/mounts", func(line []byte) error { lineread.File("/proc/mounts", func(line []byte) error {
if mem.Contains(mem.B(line), mem.S("fuse.lxcfs")) { if mem.Contains(mem.B(line), mem.S("fuse.lxcfs")) {
ret = true ret.Set(true)
return io.EOF return io.EOF
} }
return nil return nil

View File

@ -8,48 +8,58 @@
package hostinfo package hostinfo
import ( import (
"fmt" "bytes"
"os" "os"
"os/exec" "os/exec"
"strings"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"tailscale.com/version/distro" "tailscale.com/version/distro"
) )
func init() { func init() {
osVersion = osVersionFreebsd osVersion = lazyOSVersion.Get
distroName = distroNameFreeBSD
distroVersion = distroVersionFreeBSD
} }
func osVersionFreebsd() string { var (
un := unix.Utsname{} lazyVersionMeta = &lazyAtomicValue[versionMeta]{f: ptrTo(freebsdVersionMeta)}
lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionFreeBSD)}
)
func distroNameFreeBSD() string {
return lazyVersionMeta.Get().DistroName
}
func distroVersionFreeBSD() string {
return lazyVersionMeta.Get().DistroVersion
}
type versionMeta struct {
DistroName string
DistroVersion string
DistroCodeName string
}
func osVersionFreeBSD() string {
var un unix.Utsname
unix.Uname(&un) unix.Uname(&un)
return unix.ByteSliceToString(un.Release[:])
}
var attrBuf strings.Builder func freebsdVersionMeta() (meta versionMeta) {
attrBuf.WriteString("; version=") d := distro.Get()
attrBuf.WriteString(unix.ByteSliceToString(un.Release[:])) meta.DistroName = string(d)
attr := attrBuf.String() switch d {
version := "FreeBSD"
switch distro.Get() {
case distro.Pfsense: case distro.Pfsense:
b, _ := os.ReadFile("/etc/version") b, _ := os.ReadFile("/etc/version")
version = fmt.Sprintf("pfSense %s", b) meta.DistroVersion = string(bytes.TrimSpace(b))
case distro.OPNsense: case distro.OPNsense:
b, err := exec.Command("opnsense-version").Output() b, _ := exec.Command("opnsense-version").Output()
if err == nil { meta.DistroVersion = string(bytes.TrimSpace(b))
version = string(b)
} else {
version = "OPNsense"
}
case distro.TrueNAS: case distro.TrueNAS:
b, err := os.ReadFile("/etc/version") b, _ := os.ReadFile("/etc/version")
if err == nil { meta.DistroVersion = string(bytes.TrimSpace(b))
version = string(b)
} else {
version = "TrueNAS"
}
} }
// the /etc/version files end in a newline return
return fmt.Sprintf("%s%s", strings.TrimSuffix(version, "\n"), attr)
} }

View File

@ -9,7 +9,6 @@
import ( import (
"bytes" "bytes"
"fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"strings" "strings"
@ -21,14 +20,39 @@
) )
func init() { func init() {
osVersion = osVersionLinux osVersion = lazyOSVersion.Get
packageType = packageTypeLinux packageType = packageTypeLinux
distroName = distroNameLinux
distroVersion = distroVersionLinux
distroCodeName = distroCodeNameLinux
if v := linuxDeviceModel(); v != "" { if v := linuxDeviceModel(); v != "" {
SetDeviceModel(v) SetDeviceModel(v)
} }
} }
var (
lazyVersionMeta = &lazyAtomicValue[versionMeta]{f: ptrTo(linuxVersionMeta)}
lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionLinux)}
)
type versionMeta struct {
DistroName string
DistroVersion string
DistroCodeName string // "jammy", etc (VERSION_CODENAME from /etc/os-release)
}
func distroNameLinux() string {
return lazyVersionMeta.Get().DistroName
}
func distroVersionLinux() string {
return lazyVersionMeta.Get().DistroVersion
}
func distroCodeNameLinux() string {
return lazyVersionMeta.Get().DistroCodeName
}
func linuxDeviceModel() string { func linuxDeviceModel() string {
for _, path := range []string{ for _, path := range []string{
// First try the Synology-specific location. // First try the Synology-specific location.
@ -52,15 +76,22 @@ func linuxDeviceModel() string {
func getQnapQtsVersion(versionInfo string) string { func getQnapQtsVersion(versionInfo string) string {
for _, field := range strings.Fields(versionInfo) { for _, field := range strings.Fields(versionInfo) {
if suffix, ok := strs.CutPrefix(field, "QTSFW_"); ok { if suffix, ok := strs.CutPrefix(field, "QTSFW_"); ok {
return "QTS " + suffix return suffix
} }
} }
return "" return ""
} }
func osVersionLinux() string { func osVersionLinux() string {
// TODO(bradfitz,dgentry): cache this, or make caller(s) cache it. var un unix.Utsname
unix.Uname(&un)
return unix.ByteSliceToString(un.Release[:])
}
func linuxVersionMeta() (meta versionMeta) {
dist := distro.Get() dist := distro.Get()
meta.DistroName = string(dist)
propFile := "/etc/os-release" propFile := "/etc/os-release"
switch dist { switch dist {
case distro.Synology: case distro.Synology:
@ -69,10 +100,12 @@ func osVersionLinux() string {
propFile = "/etc/openwrt_release" propFile = "/etc/openwrt_release"
case distro.WDMyCloud: case distro.WDMyCloud:
slurp, _ := ioutil.ReadFile("/etc/version") slurp, _ := ioutil.ReadFile("/etc/version")
return fmt.Sprintf("%s", string(bytes.TrimSpace(slurp))) meta.DistroVersion = string(bytes.TrimSpace(slurp))
return
case distro.QNAP: case distro.QNAP:
slurp, _ := ioutil.ReadFile("/etc/version_info") slurp, _ := ioutil.ReadFile("/etc/version_info")
return getQnapQtsVersion(string(slurp)) meta.DistroVersion = getQnapQtsVersion(string(slurp))
return
} }
m := map[string]string{} m := map[string]string{}
@ -86,50 +119,45 @@ func osVersionLinux() string {
return nil return nil
}) })
var un unix.Utsname if v := m["VERSION_CODENAME"]; v != "" {
unix.Uname(&un) meta.DistroCodeName = v
var attrBuf strings.Builder
attrBuf.WriteString("; kernel=")
attrBuf.WriteString(unix.ByteSliceToString(un.Release[:]))
if inContainer() {
attrBuf.WriteString("; container")
} }
if env := GetEnvType(); env != "" { if v := m["VERSION_ID"]; v != "" {
fmt.Fprintf(&attrBuf, "; env=%s", env) meta.DistroVersion = v
} }
attr := attrBuf.String()
id := m["ID"] id := m["ID"]
if id != "" {
meta.DistroName = id
}
switch id { switch id {
case "debian": case "debian":
// Debian's VERSION_ID is just like "11". But /etc/debian_version has "11.5" normally.
// Or "bookworm/sid" on sid/testing.
slurp, _ := ioutil.ReadFile("/etc/debian_version") slurp, _ := ioutil.ReadFile("/etc/debian_version")
return fmt.Sprintf("Debian %s (%s)%s", bytes.TrimSpace(slurp), m["VERSION_CODENAME"], attr) if v := string(bytes.TrimSpace(slurp)); v != "" {
case "ubuntu": if '0' <= v[0] && v[0] <= '9' {
return fmt.Sprintf("Ubuntu %s%s", m["VERSION"], attr) meta.DistroVersion = v
} else if meta.DistroCodeName == "" {
meta.DistroCodeName = v
}
}
case "", "centos": // CentOS 6 has no /etc/os-release, so its id is "" 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) if meta.DistroVersion == "" {
return fmt.Sprintf("%s%s", bytes.TrimSpace(cr), attr) if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final)
} meta.DistroVersion = string(bytes.TrimSpace(cr))
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)
} }
} }
if v := m["PRETTY_NAME"]; v != "" && meta.DistroVersion == "" && !strings.HasSuffix(v, "/sid") {
meta.DistroVersion = v
}
switch dist { switch dist {
case distro.Synology: case distro.Synology:
return fmt.Sprintf("Synology %s%s", m["productversion"], attr) meta.DistroVersion = m["productversion"]
case distro.OpenWrt: case distro.OpenWrt:
return fmt.Sprintf("OpenWrt %s%s", m["DISTRIB_RELEASE"], attr) meta.DistroVersion = m["DISTRIB_RELEASE"]
case distro.Gokrazy:
return fmt.Sprintf("Gokrazy%s", attr)
} }
return fmt.Sprintf("Other%s", attr) return
} }
func packageTypeLinux() string { func packageTypeLinux() string {

View File

@ -19,7 +19,7 @@ func TestQnap(t *testing.T) {
remotes/origin/QTSFW_5.0.0` remotes/origin/QTSFW_5.0.0`
got := getQnapQtsVersion(version_info) got := getQnapQtsVersion(version_info)
want := "QTS 5.0.0" want := "5.0.0"
if got != want { if got != want {
t.Errorf("got %q; want %q", got, want) t.Errorf("got %q; want %q", got, want)
} }

View File

@ -11,21 +11,20 @@
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/registry"
"tailscale.com/syncs"
"tailscale.com/util/winutil" "tailscale.com/util/winutil"
) )
func init() { func init() {
osVersion = osVersionWindows osVersion = lazyOSVersion.Get
packageType = packageTypeWindows packageType = lazyPackageType.Get
} }
var winVerCache syncs.AtomicValue[string] var (
lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionWindows)}
lazyPackageType = &lazyAtomicValue[string]{f: ptrTo(packageTypeWindows)}
)
func osVersionWindows() string { func osVersionWindows() string {
if s, ok := winVerCache.LoadOk(); ok {
return s
}
major, minor, build := windows.RtlGetNtVersionNumbers() major, minor, build := windows.RtlGetNtVersionNumbers()
s := fmt.Sprintf("%d.%d.%d", major, minor, build) s := fmt.Sprintf("%d.%d.%d", major, minor, build)
// Windows 11 still uses 10 as its major number internally // Windows 11 still uses 10 as its major number internally
@ -34,9 +33,6 @@ func osVersionWindows() string {
s += fmt.Sprintf(".%d", ubr) s += fmt.Sprintf(".%d", ubr)
} }
} }
if s != "" {
winVerCache.Store(s)
}
return s // "10.0.19041.388", ideally return s // "10.0.19041.388", ideally
} }

View File

@ -466,11 +466,29 @@ type Service struct {
// Because it contains pointers (slices), this type should not be used // Because it contains pointers (slices), this type should not be used
// as a value type. // as a value type.
type Hostinfo struct { type Hostinfo struct {
IPNVersion string `json:",omitempty"` // version of this code IPNVersion string `json:",omitempty"` // version of this code (in version.Long format)
FrontendLogID string `json:",omitempty"` // logtail ID of frontend instance FrontendLogID string `json:",omitempty"` // logtail ID of frontend instance
BackendLogID string `json:",omitempty"` // logtail ID of backend instance BackendLogID string `json:",omitempty"` // logtail ID of backend instance
OS string `json:",omitempty"` // operating system the client runs on (a version.OS value) OS string `json:",omitempty"` // 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")
// OSVersion is the version of the OS, if available.
//
// For Android, it's like "10", "11", "12", etc. For iOS and macOS it's like
// "15.6.1" or "12.4.0". For Windows it's like "10.0.19044.1889". For
// FreeBSD it's like "12.3-STABLE".
//
// For Linux, prior to Tailscale 1.32, we jammed a bunch of fields into this
// string on Linux, like "Debian 10.4; kernel=xxx; container; env=kn" and so
// on. As of Tailscale 1.32, this is simply the kernel version on Linux, like
// "5.10.0-17-amd64".
OSVersion string `json:",omitempty"`
Container opt.Bool `json:",omitempty"` // whether the client is running in a container
Env string `json:",omitempty"` // a hostinfo.EnvType in string form
Distro string `json:",omitempty"` // "debian", "ubuntu", "nixos", ...
DistroVersion string `json:",omitempty"` // "20.04", ...
DistroCodeName string `json:",omitempty"` // "jammy", "bullseye", ...
Desktop opt.Bool `json:",omitempty"` // if a desktop was detected on Linux Desktop opt.Bool `json:",omitempty"` // if a desktop was detected on Linux
Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown) Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown)
DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3") DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3")

View File

@ -120,6 +120,11 @@ func (src *Hostinfo) Clone() *Hostinfo {
BackendLogID string BackendLogID string
OS string OS string
OSVersion string OSVersion string
Container opt.Bool
Env string
Distro string
DistroVersion string
DistroCodeName string
Desktop opt.Bool Desktop opt.Bool
Package string Package string
DeviceModel string DeviceModel string

View File

@ -31,13 +31,32 @@ func fieldsOf(t reflect.Type) (fields []string) {
func TestHostinfoEqual(t *testing.T) { func TestHostinfoEqual(t *testing.T) {
hiHandles := []string{ hiHandles := []string{
"IPNVersion", "FrontendLogID", "BackendLogID", "IPNVersion",
"OS", "OSVersion", "Desktop", "Package", "DeviceModel", "Hostname", "FrontendLogID",
"ShieldsUp", "ShareeNode", "BackendLogID",
"GoArch", "GoVersion", "OS",
"RoutableIPs", "RequestTags", "OSVersion",
"Services", "NetInfo", "SSH_HostKeys", "Cloud", "Container",
"Userspace", "UserspaceRouter", "Env",
"Distro",
"DistroVersion",
"DistroCodeName",
"Desktop",
"Package",
"DeviceModel",
"Hostname",
"ShieldsUp",
"ShareeNode",
"GoArch",
"GoVersion",
"RoutableIPs",
"RequestTags",
"Services",
"NetInfo",
"SSH_HostKeys",
"Cloud",
"Userspace",
"UserspaceRouter",
} }
if have := fieldsOf(reflect.TypeOf(Hostinfo{})); !reflect.DeepEqual(have, hiHandles) { if have := fieldsOf(reflect.TypeOf(Hostinfo{})); !reflect.DeepEqual(have, hiHandles) {
t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n", t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n",

View File

@ -250,19 +250,24 @@ func (v *HostinfoView) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion } func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion }
func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID } func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID }
func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID } func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID }
func (v HostinfoView) OS() string { return v.ж.OS } func (v HostinfoView) OS() string { return v.ж.OS }
func (v HostinfoView) OSVersion() string { return v.ж.OSVersion } func (v HostinfoView) OSVersion() string { return v.ж.OSVersion }
func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop } func (v HostinfoView) Container() opt.Bool { return v.ж.Container }
func (v HostinfoView) Package() string { return v.ж.Package } func (v HostinfoView) Env() string { return v.ж.Env }
func (v HostinfoView) DeviceModel() string { return v.ж.DeviceModel } func (v HostinfoView) Distro() string { return v.ж.Distro }
func (v HostinfoView) Hostname() string { return v.ж.Hostname } func (v HostinfoView) DistroVersion() string { return v.ж.DistroVersion }
func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp } func (v HostinfoView) DistroCodeName() string { return v.ж.DistroCodeName }
func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode } func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop }
func (v HostinfoView) GoArch() string { return v.ж.GoArch } func (v HostinfoView) Package() string { return v.ж.Package }
func (v HostinfoView) GoVersion() string { return v.ж.GoVersion } func (v HostinfoView) DeviceModel() string { return v.ж.DeviceModel }
func (v HostinfoView) Hostname() string { return v.ж.Hostname }
func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp }
func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode }
func (v HostinfoView) GoArch() string { return v.ж.GoArch }
func (v HostinfoView) GoVersion() string { return v.ж.GoVersion }
func (v HostinfoView) RoutableIPs() views.IPPrefixSlice { func (v HostinfoView) RoutableIPs() views.IPPrefixSlice {
return views.IPPrefixSliceOf(v.ж.RoutableIPs) return views.IPPrefixSliceOf(v.ж.RoutableIPs)
} }
@ -282,6 +287,11 @@ func (v HostinfoView) Equal(v2 HostinfoView) bool { return v.ж.Equal(v2.
BackendLogID string BackendLogID string
OS string OS string
OSVersion string OSVersion string
Container opt.Bool
Env string
Distro string
DistroVersion string
DistroCodeName string
Desktop opt.Bool Desktop opt.Bool
Package string Package string
DeviceModel string DeviceModel string