diff --git a/updater/update/export_test.go b/updater/update/export_test.go new file mode 100644 index 0000000..9ffd677 --- /dev/null +++ b/updater/update/export_test.go @@ -0,0 +1,16 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +// Test-only exports for black-box tests in package update_test. +var ( + SetOSHeaders = setOSHeaders + TrimToNumericVersion = trimToNumericVersion + UserAgent = userAgent +) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go new file mode 100644 index 0000000..38cbf76 --- /dev/null +++ b/updater/update/osinfo.go @@ -0,0 +1,55 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "fmt" + "net/http" + "runtime" + "strings" +) + +const ( + headerOSTypeKey string = "X-OS-Type" + headerOSVersionKey string = "X-OS-Version" + headerOSArchKey string = "X-OS-Arch" + headerBinaryArchKey string = "X-Binary-Arch" +) + +// trimToNumericVersion trims a version string to its leading dot-separated +// numeric part, dropping suffixes like the distro patch, kernel flavor and +// architecture in a Linux kernel release ("5.15.0-91-generic" → "5.15.0"). +// The update server compares versions segment-by-segment numerically, so +// only digits and dots may be sent. +func trimToNumericVersion(version string) string { + end := 0 + for end < len(version) && (version[end] == '.' || ('0' <= version[end] && version[end] <= '9')) { + end++ + } + return strings.TrimRight(version[:end], ".") +} + +// setOSHeaders sets the OS identity headers used by the update server to +// select a build compatible with this host. The server keys build selection +// on X-OS-Type + X-OS-Arch; X-Binary-Arch reports what the running updater +// was compiled for, which differs from X-OS-Arch under emulation (Rosetta 2, +// Windows-on-ARM) so those installs migrate to native builds. +func setOSHeaders(req *http.Request) { + req.Header.Set(headerOSTypeKey, runtime.GOOS) + // Best-effort host architecture: detects Rosetta 2 and Windows-on-ARM + // emulation; on other platforms falls back to the binary's arch. + req.Header.Set(headerOSArchKey, nativeArch()) + req.Header.Set(headerBinaryArchKey, runtime.GOARCH) + // Best effort: an unknown OS version is better than a failed update check. + if version, err := osVersion(); err == nil { + req.Header.Set(headerOSVersionKey, version) + } else { + fmt.Printf("Couldn't detect OS version: %v.\n", err) + } +} diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go new file mode 100644 index 0000000..320efd6 --- /dev/null +++ b/updater/update/osinfo_test.go @@ -0,0 +1,85 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update_test + +import ( + "net/http" + "regexp" + "runtime" + "testing" + + "github.com/papercutsoftware/silver/updater/update" +) + +func TestUserAgent(t *testing.T) { + got := update.UserAgent() + + // "silver-updater" alone (no VCS stamp, e.g. test binaries), or + // "silver-updater/" where version is a spaceless token. + if matched := regexp.MustCompile(`^silver-updater(/\S+)?$`).MatchString(got); !matched { + t.Errorf("userAgent() = %q, want \"silver-updater\" optionally followed by /", got) + } +} + +func TestTrimToNumericVersion(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"vanilla kernel", "6.17.24", "6.17.24"}, + {"ubuntu", "5.15.0-91-generic", "5.15.0"}, + {"debian", "6.1.0-13-amd64", "6.1.0"}, + {"rhel", "4.18.0-477.10.1.el8_8.x86_64", "4.18.0"}, + {"amazon linux", "6.1.66-91.160.amzn2023.x86_64", "6.1.66"}, + {"no trailing dot kept", "5.15.", "5.15"}, + {"non-numeric prefix", "generic-5.15", ""}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := update.TrimToNumericVersion(tt.input); got != tt.want { + t.Errorf("TrimToNumericVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSetOSHeaders(t *testing.T) { + req, err := http.NewRequest("GET", "https://example.com/check-update/test", nil) + if err != nil { + t.Fatal(err) + } + + update.SetOSHeaders(req) + + if got := req.Header.Get("X-OS-Type"); got != runtime.GOOS { + t.Errorf("X-OS-Type = %q, want %q", got, runtime.GOOS) + } + if got := req.Header.Get("X-Binary-Arch"); got != runtime.GOARCH { + t.Errorf("X-Binary-Arch = %q, want %q", got, runtime.GOARCH) + } + + // OS arch is the binary arch except under emulation, where it must + // be arm64 (Rosetta 2 and Windows-on-ARM both emulate on arm64 hosts). + osArchGot := req.Header.Get("X-OS-Arch") + if osArchGot != runtime.GOARCH && osArchGot != "arm64" { + t.Errorf("X-OS-Arch = %q, want %q or \"arm64\"", osArchGot, runtime.GOARCH) + } + + // windows, darwin and linux all report a dot-separated numeric version. + switch runtime.GOOS { + case "windows", "darwin", "linux": + got := req.Header.Get("X-OS-Version") + if matched := regexp.MustCompile(`^\d+(\.\d+)+`).MatchString(got); !matched { + t.Errorf("X-OS-Version = %q, want a dot-separated numeric version", got) + } + } +} diff --git a/updater/update/osnativearch_darwin.go b/updater/update/osnativearch_darwin.go new file mode 100644 index 0000000..02bc91f --- /dev/null +++ b/updater/update/osnativearch_darwin.go @@ -0,0 +1,25 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "runtime" + "syscall" +) + +// nativeArch returns the host CPU architecture, which differs from +// runtime.GOARCH when an x64 binary runs under Rosetta 2 translation. +// sysctl.proc_translated is 1 for translated processes; the sysctl does +// not exist on Intel Macs (Sysctl returns an error). +func nativeArch() string { + if translated, err := syscall.SysctlUint32("sysctl.proc_translated"); err == nil && translated == 1 { + return "arm64" + } + return runtime.GOARCH +} diff --git a/updater/update/osnativearch_other.go b/updater/update/osnativearch_other.go new file mode 100644 index 0000000..8a8de69 --- /dev/null +++ b/updater/update/osnativearch_other.go @@ -0,0 +1,20 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build !windows && !darwin + +package update + +import "runtime" + +// nativeArch assumes the host architecture matches the binary on platforms +// without a reliable emulation signal (user-mode emulation on Linux is rare +// and deliberately hides itself from uname). +func nativeArch() string { + return runtime.GOARCH +} diff --git a/updater/update/osnativearch_windows.go b/updater/update/osnativearch_windows.go new file mode 100644 index 0000000..8fba3f6 --- /dev/null +++ b/updater/update/osnativearch_windows.go @@ -0,0 +1,38 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "runtime" + + "golang.org/x/sys/windows" +) + +// nativeArch returns the host CPU architecture, which differs from +// runtime.GOARCH when the binary runs under emulation (e.g. x64 on +// Windows-on-ARM). IsWow64Process2 reports the native machine regardless +// of emulation; it is unavailable before Windows 10 1511, but no such +// host is an ARM64 machine, so falling back to GOARCH is correct there. +func nativeArch() string { + var processMachine, nativeMachine uint16 + if err := windows.IsWow64Process2(windows.CurrentProcess(), &processMachine, &nativeMachine); err != nil { + return runtime.GOARCH + } + switch nativeMachine { + case 0xAA64: // IMAGE_FILE_MACHINE_ARM64 + return "arm64" + case 0x8664: // IMAGE_FILE_MACHINE_AMD64 + return "amd64" + case 0x014C: // IMAGE_FILE_MACHINE_I386 + return "386" + case 0x01C4: // IMAGE_FILE_MACHINE_ARMNT + return "arm" + } + return runtime.GOARCH +} diff --git a/updater/update/osversion_darwin.go b/updater/update/osversion_darwin.go new file mode 100644 index 0000000..666381a --- /dev/null +++ b/updater/update/osversion_darwin.go @@ -0,0 +1,26 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "errors" + "syscall" +) + +// osVersion returns the macOS product version (e.g. "14.5"). +func osVersion() (string, error) { + version, err := syscall.Sysctl("kern.osproductversion") + if err != nil { + return "", err + } + if version == "" { + return "", errors.New("kern.osproductversion is empty") + } + return version, nil +} diff --git a/updater/update/osversion_linux.go b/updater/update/osversion_linux.go new file mode 100644 index 0000000..62204f4 --- /dev/null +++ b/updater/update/osversion_linux.go @@ -0,0 +1,30 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// osVersion returns the Linux kernel version (e.g. "5.15.0") via uname, +// trimming distro suffixes from the release string ("5.15.0-91-generic"). +func osVersion() (string, error) { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return "", err + } + release := unix.ByteSliceToString(uts.Release[:]) + version := trimToNumericVersion(release) + if version == "" { + return "", fmt.Errorf("unrecognized kernel release %q", release) + } + return version, nil +} diff --git a/updater/update/osversion_other.go b/updater/update/osversion_other.go new file mode 100644 index 0000000..4682036 --- /dev/null +++ b/updater/update/osversion_other.go @@ -0,0 +1,22 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build !windows && !darwin && !linux + +package update + +import ( + "fmt" + "runtime" +) + +// osVersion has no detection on this platform; the caller omits the +// X-OS-Version header on error. +func osVersion() (string, error) { + return "", fmt.Errorf("no OS version detection support for %s", runtime.GOOS) +} diff --git a/updater/update/osversion_windows.go b/updater/update/osversion_windows.go new file mode 100644 index 0000000..55e1b6b --- /dev/null +++ b/updater/update/osversion_windows.go @@ -0,0 +1,23 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// osVersion returns the Windows version as major.minor.build (e.g. +// "10.0.19041"). RtlGetVersion reports the true OS version, unaffected by +// the compatibility shims that skew GetVersionEx. +func osVersion() (string, error) { + info := windows.RtlGetVersion() + return fmt.Sprintf("%d.%d.%d", info.MajorVersion, info.MinorVersion, info.BuildNumber), nil +} diff --git a/updater/update/update.go b/updater/update/update.go index 46f7f33..c84c9fb 100644 --- a/updater/update/update.go +++ b/updater/update/update.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2014-2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // @@ -39,8 +39,9 @@ func Check(updateURL string, currentVer string, publicKey string) (*UpgradeInfo, if err != nil { return nil, err } - req.Header.Set("User-Agent", "Update Check") + req.Header.Set("User-Agent", userAgent()) addIDProfileToRequestHeader(req) + setOSHeaders(req) res, err := client.Do(req) if err != nil { diff --git a/updater/update/useragent.go b/updater/update/useragent.go new file mode 100644 index 0000000..287af02 --- /dev/null +++ b/updater/update/useragent.go @@ -0,0 +1,24 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import "runtime/debug" + +// userAgent returns the User-Agent for update check requests, e.g. +// "silver-updater/v1.6.1". The version is stamped automatically by the Go +// toolchain from the module's VCS tag; builds without VCS information +// (or from an untagged tree reporting "(devel)") omit it. +func userAgent() string { + if info, ok := debug.ReadBuildInfo(); ok { + if v := info.Main.Version; v != "" && v != "(devel)" { + return "silver-updater/" + v + } + } + return "silver-updater" +}