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>
pull/5599/head
Brad Fitzpatrick 2 years ago committed by Brad Fitzpatrick
parent 708b7bff3d
commit d5e7e3093d

@ -12,6 +12,7 @@ import (
"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

@ -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)}
unix.Uname(&un) lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionFreeBSD)}
)
func distroNameFreeBSD() string {
return lazyVersionMeta.Get().DistroName
}
var attrBuf strings.Builder func distroVersionFreeBSD() string {
attrBuf.WriteString("; version=") return lazyVersionMeta.Get().DistroVersion
attrBuf.WriteString(unix.ByteSliceToString(un.Release[:])) }
attr := attrBuf.String()
version := "FreeBSD" type versionMeta struct {
switch distro.Get() { DistroName string
DistroVersion string
DistroCodeName string
}
func osVersionFreeBSD() string {
var un unix.Utsname
unix.Uname(&un)
return unix.ByteSliceToString(un.Release[:])
}
func freebsdVersionMeta() (meta versionMeta) {
d := distro.Get()
meta.DistroName = string(d)
switch d {
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)
} }

@ -9,7 +9,6 @@ package hostinfo
import ( import (
"bytes" "bytes"
"fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"strings" "strings"
@ -21,14 +20,39 @@ import (
) )
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
case "", "centos": // CentOS 6 has no /etc/os-release, so its id is "" } else if meta.DistroCodeName == "" {
if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final) meta.DistroCodeName = v
return fmt.Sprintf("%s%s", bytes.TrimSpace(cr), attr) }
} }
fallthrough case "", "centos": // CentOS 6 has no /etc/os-release, so its id is ""
case "fedora", "rhel", "alpine", "nixos": if meta.DistroVersion == "" {
// Their PRETTY_NAME is fine as-is for all versions I tested. if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final)
fallthrough meta.DistroVersion = string(bytes.TrimSpace(cr))
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 {

@ -19,7 +19,7 @@ Date: 2022-05-30 16:08:45 +0800
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)
} }

@ -11,21 +11,20 @@ import (
"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
} }

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

@ -120,6 +120,11 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
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

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

@ -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 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
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

Loading…
Cancel
Save