diff --git a/hostinfo/hostinfo.go b/hostinfo/hostinfo.go index ba03aae7f..ca9ea718b 100644 --- a/hostinfo/hostinfo.go +++ b/hostinfo/hostinfo.go @@ -12,6 +12,7 @@ "os" "runtime" "strings" + "sync" "sync/atomic" "time" @@ -31,25 +32,68 @@ func New() *tailcfg.Hostinfo { hostname, _ := os.Hostname() hostname = dnsname.FirstLabel(hostname) return &tailcfg.Hostinfo{ - IPNVersion: version.Long, - Hostname: hostname, - OS: version.OS(), - OSVersion: GetOSVersion(), - Desktop: desktop(), - Package: packageTypeCached(), - GoArch: runtime.GOARCH, - GoVersion: runtime.Version(), - DeviceModel: deviceModel(), - Cloud: string(cloudenv.Get()), + IPNVersion: version.Long, + Hostname: hostname, + OS: version.OS(), + OSVersion: GetOSVersion(), + Container: lazyInContainer.Get(), + Distro: condCall(distroName), + DistroVersion: condCall(distroVersion), + DistroCodeName: condCall(distroCodeName), + Env: string(GetEnvType()), + Desktop: desktop(), + Package: packageTypeCached(), + GoArch: runtime.GOARCH, + GoVersion: runtime.Version(), + DeviceModel: deviceModel(), + Cloud: string(cloudenv.Get()), } } // non-nil on some platforms var ( - osVersion func() string - packageType func() string + osVersion 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. func GetOSVersion() string { if s, _ := osVersionAtomic.Load().(string); s != "" { @@ -179,22 +223,23 @@ func getEnvType() EnvType { } // inContainer reports whether we're running in a container. -func inContainer() bool { +func inContainer() opt.Bool { 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 { if mem.Contains(mem.B(line), mem.S("/docker/")) || mem.Contains(mem.B(line), mem.S("/lxc/")) { - ret = true + ret.Set(true) return io.EOF // arbitrary non-nil error to stop loop } return nil }) lineread.File("/proc/mounts", func(line []byte) error { if mem.Contains(mem.B(line), mem.S("fuse.lxcfs")) { - ret = true + ret.Set(true) return io.EOF } return nil diff --git a/hostinfo/hostinfo_freebsd.go b/hostinfo/hostinfo_freebsd.go index ffb7417c1..c8ed9fa34 100644 --- a/hostinfo/hostinfo_freebsd.go +++ b/hostinfo/hostinfo_freebsd.go @@ -8,48 +8,58 @@ package hostinfo import ( - "fmt" + "bytes" "os" "os/exec" - "strings" "golang.org/x/sys/unix" "tailscale.com/version/distro" ) func init() { - osVersion = osVersionFreebsd + osVersion = lazyOSVersion.Get + distroName = distroNameFreeBSD + distroVersion = distroVersionFreeBSD } -func osVersionFreebsd() string { - un := unix.Utsname{} +var ( + 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) + return unix.ByteSliceToString(un.Release[:]) +} - var attrBuf strings.Builder - attrBuf.WriteString("; version=") - attrBuf.WriteString(unix.ByteSliceToString(un.Release[:])) - attr := attrBuf.String() - - version := "FreeBSD" - switch distro.Get() { +func freebsdVersionMeta() (meta versionMeta) { + d := distro.Get() + meta.DistroName = string(d) + switch d { case distro.Pfsense: b, _ := os.ReadFile("/etc/version") - version = fmt.Sprintf("pfSense %s", b) + meta.DistroVersion = string(bytes.TrimSpace(b)) case distro.OPNsense: - b, err := exec.Command("opnsense-version").Output() - if err == nil { - version = string(b) - } else { - version = "OPNsense" - } + b, _ := exec.Command("opnsense-version").Output() + meta.DistroVersion = string(bytes.TrimSpace(b)) case distro.TrueNAS: - b, err := os.ReadFile("/etc/version") - if err == nil { - version = string(b) - } else { - version = "TrueNAS" - } + b, _ := os.ReadFile("/etc/version") + meta.DistroVersion = string(bytes.TrimSpace(b)) } - // the /etc/version files end in a newline - return fmt.Sprintf("%s%s", strings.TrimSuffix(version, "\n"), attr) + return } diff --git a/hostinfo/hostinfo_linux.go b/hostinfo/hostinfo_linux.go index 02423cb33..522eab385 100644 --- a/hostinfo/hostinfo_linux.go +++ b/hostinfo/hostinfo_linux.go @@ -9,7 +9,6 @@ import ( "bytes" - "fmt" "io/ioutil" "os" "strings" @@ -21,14 +20,39 @@ ) func init() { - osVersion = osVersionLinux + osVersion = lazyOSVersion.Get packageType = packageTypeLinux - + distroName = distroNameLinux + distroVersion = distroVersionLinux + distroCodeName = distroCodeNameLinux if v := linuxDeviceModel(); 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 { for _, path := range []string{ // First try the Synology-specific location. @@ -52,15 +76,22 @@ func linuxDeviceModel() string { func getQnapQtsVersion(versionInfo string) string { for _, field := range strings.Fields(versionInfo) { if suffix, ok := strs.CutPrefix(field, "QTSFW_"); ok { - return "QTS " + suffix + return suffix } } return "" } 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() + meta.DistroName = string(dist) + propFile := "/etc/os-release" switch dist { case distro.Synology: @@ -69,10 +100,12 @@ func osVersionLinux() string { propFile = "/etc/openwrt_release" case distro.WDMyCloud: slurp, _ := ioutil.ReadFile("/etc/version") - return fmt.Sprintf("%s", string(bytes.TrimSpace(slurp))) + meta.DistroVersion = string(bytes.TrimSpace(slurp)) + return case distro.QNAP: slurp, _ := ioutil.ReadFile("/etc/version_info") - return getQnapQtsVersion(string(slurp)) + meta.DistroVersion = getQnapQtsVersion(string(slurp)) + return } m := map[string]string{} @@ -86,50 +119,45 @@ func osVersionLinux() string { return nil }) - var un unix.Utsname - unix.Uname(&un) - - var attrBuf strings.Builder - attrBuf.WriteString("; kernel=") - attrBuf.WriteString(unix.ByteSliceToString(un.Release[:])) - if inContainer() { - attrBuf.WriteString("; container") + if v := m["VERSION_CODENAME"]; v != "" { + meta.DistroCodeName = v } - if env := GetEnvType(); env != "" { - fmt.Fprintf(&attrBuf, "; env=%s", env) + if v := m["VERSION_ID"]; v != "" { + meta.DistroVersion = v } - attr := attrBuf.String() - id := m["ID"] - + if id != "" { + meta.DistroName = id + } switch id { 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") - 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) + if v := string(bytes.TrimSpace(slurp)); v != "" { + if '0' <= v[0] && v[0] <= '9' { + meta.DistroVersion = v + } else if meta.DistroCodeName == "" { + meta.DistroCodeName = v + } + } 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) + if meta.DistroVersion == "" { + if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final) + meta.DistroVersion = string(bytes.TrimSpace(cr)) + } } } + if v := m["PRETTY_NAME"]; v != "" && meta.DistroVersion == "" && !strings.HasSuffix(v, "/sid") { + meta.DistroVersion = v + } switch dist { case distro.Synology: - return fmt.Sprintf("Synology %s%s", m["productversion"], attr) + meta.DistroVersion = m["productversion"] case distro.OpenWrt: - return fmt.Sprintf("OpenWrt %s%s", m["DISTRIB_RELEASE"], attr) - case distro.Gokrazy: - return fmt.Sprintf("Gokrazy%s", attr) + meta.DistroVersion = m["DISTRIB_RELEASE"] } - return fmt.Sprintf("Other%s", attr) + return } func packageTypeLinux() string { diff --git a/hostinfo/hostinfo_linux_test.go b/hostinfo/hostinfo_linux_test.go index 4bb8a83d9..ba68c9257 100644 --- a/hostinfo/hostinfo_linux_test.go +++ b/hostinfo/hostinfo_linux_test.go @@ -19,7 +19,7 @@ func TestQnap(t *testing.T) { remotes/origin/QTSFW_5.0.0` got := getQnapQtsVersion(version_info) - want := "QTS 5.0.0" + want := "5.0.0" if got != want { t.Errorf("got %q; want %q", got, want) } diff --git a/hostinfo/hostinfo_windows.go b/hostinfo/hostinfo_windows.go index 4b8ba40ef..b41aa230f 100644 --- a/hostinfo/hostinfo_windows.go +++ b/hostinfo/hostinfo_windows.go @@ -11,21 +11,20 @@ "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "tailscale.com/syncs" "tailscale.com/util/winutil" ) func init() { - osVersion = osVersionWindows - packageType = packageTypeWindows + osVersion = lazyOSVersion.Get + packageType = lazyPackageType.Get } -var winVerCache syncs.AtomicValue[string] +var ( + lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionWindows)} + lazyPackageType = &lazyAtomicValue[string]{f: ptrTo(packageTypeWindows)} +) func osVersionWindows() string { - if s, ok := winVerCache.LoadOk(); ok { - return s - } major, minor, build := windows.RtlGetNtVersionNumbers() s := fmt.Sprintf("%d.%d.%d", major, minor, build) // Windows 11 still uses 10 as its major number internally @@ -34,9 +33,6 @@ func osVersionWindows() string { s += fmt.Sprintf(".%d", ubr) } } - if s != "" { - winVerCache.Store(s) - } return s // "10.0.19041.388", ideally } diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 0f8e7d0cc..514a01bdd 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -466,11 +466,29 @@ type Service struct { // Because it contains pointers (slices), this type should not be used // as a value type. type Hostinfo struct { - IPNVersion string `json:",omitempty"` // version of this code - FrontendLogID string `json:",omitempty"` // logtail ID of frontend instance - BackendLogID string `json:",omitempty"` // logtail ID of backend instance - 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") + IPNVersion string `json:",omitempty"` // version of this code (in version.Long format) + FrontendLogID string `json:",omitempty"` // logtail ID of frontend instance + BackendLogID string `json:",omitempty"` // logtail ID of backend instance + OS string `json:",omitempty"` // operating system the client runs on (a version.OS value) + + // 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 Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown) DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3") diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 977a6105c..f89bc6c2f 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -120,6 +120,11 @@ func (src *Hostinfo) Clone() *Hostinfo { BackendLogID string OS string OSVersion string + Container opt.Bool + Env string + Distro string + DistroVersion string + DistroCodeName string Desktop opt.Bool Package string DeviceModel string diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index 8f13c9f20..ce13bef37 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -31,13 +31,32 @@ func fieldsOf(t reflect.Type) (fields []string) { func TestHostinfoEqual(t *testing.T) { hiHandles := []string{ - "IPNVersion", "FrontendLogID", "BackendLogID", - "OS", "OSVersion", "Desktop", "Package", "DeviceModel", "Hostname", - "ShieldsUp", "ShareeNode", - "GoArch", "GoVersion", - "RoutableIPs", "RequestTags", - "Services", "NetInfo", "SSH_HostKeys", "Cloud", - "Userspace", "UserspaceRouter", + "IPNVersion", + "FrontendLogID", + "BackendLogID", + "OS", + "OSVersion", + "Container", + "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) { t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 5dcf69af3..d06489bda 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -250,19 +250,24 @@ func (v *HostinfoView) UnmarshalJSON(b []byte) error { return nil } -func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion } -func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID } -func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID } -func (v HostinfoView) OS() string { return v.ж.OS } -func (v HostinfoView) OSVersion() string { return v.ж.OSVersion } -func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop } -func (v HostinfoView) Package() string { return v.ж.Package } -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) IPNVersion() string { return v.ж.IPNVersion } +func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID } +func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID } +func (v HostinfoView) OS() string { return v.ж.OS } +func (v HostinfoView) OSVersion() string { return v.ж.OSVersion } +func (v HostinfoView) Container() opt.Bool { return v.ж.Container } +func (v HostinfoView) Env() string { return v.ж.Env } +func (v HostinfoView) Distro() string { return v.ж.Distro } +func (v HostinfoView) DistroVersion() string { return v.ж.DistroVersion } +func (v HostinfoView) DistroCodeName() string { return v.ж.DistroCodeName } +func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop } +func (v HostinfoView) Package() string { return v.ж.Package } +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 { return views.IPPrefixSliceOf(v.ж.RoutableIPs) } @@ -282,6 +287,11 @@ func (v HostinfoView) Equal(v2 HostinfoView) bool { return v.ж.Equal(v2. BackendLogID string OS string OSVersion string + Container opt.Bool + Env string + Distro string + DistroVersion string + DistroCodeName string Desktop opt.Bool Package string DeviceModel string