DEVICE-002: ARM machine-ID fallback — device-tree model + /etc/machine-id combo, then /proc/cpuinfo Serial (all-zero rejected), before the weak hostname fallback. Hardware-bound IDs on DMI-less devices. DEVICE-001: agent detects device_type (server/desktop/phone/tablet) from /sys signals — system battery (scope=Device peripherals excluded, UPS excluded), DRM connector state, framebuffer min-dimension for phone/tablet split. Reports device_type/device_model/os_distro in registration and system-info paths. SERVER-001: migration 061 — device_type, device_type_manual (operator override, never agent-written), device_model, os_distro on agents. effective_device_type computed into every serialized agent. SERVER-002: PUT /admin/agents/:id/device-type — set/clear override, enum-validated, journaled. WEB-001: device-type icons + fleet filter, device model in list, detail header badge with reclassify dropdown, os_distro surfaced. INSTALL-003: arm64 install path unblocked — helper (required manifest component) now cross-built aarch64-unknown-linux-musl via rust-lld in the server image, signed at boot (helperArches += arm64), listed in the release manifest. Install template already handled uname -m and pacman. Plus in-flight: desktop tray wiring, enrollment page polish, CI workflow updates, RAF session-broker/pacman-scanner docs, native installer scaffold.
166 lines
4.7 KiB
Go
166 lines
4.7 KiB
Go
package system
|
||
|
||
// device.go — DEVICE-001: device form-factor detection.
|
||
//
|
||
// Classifies the host as server / desktop / phone / tablet from hardware
|
||
// signals. The agent runs as a systemd service, so session environment
|
||
// (DISPLAY, WAYLAND_DISPLAY) is useless here — every signal is read from
|
||
// /sys and /proc. The server stores this as device_type; the operator can
|
||
// override it (device_type_manual), so misclassification is recoverable.
|
||
//
|
||
// Classification matrix (DEVICE-001):
|
||
//
|
||
// battery=no display=no → server
|
||
// battery=no display=yes → desktop
|
||
// battery=yes display=no → phone
|
||
// battery=yes display=yes → phone or tablet by screen size
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// phoneMaxMinDimension is the phone/tablet split on the framebuffer's smaller
|
||
// dimension in pixels. Phones run 720–1440 on the short edge; tablets start
|
||
// around 1600. The task's raw width<1024 test misfires on any modern phone
|
||
// (Pixel 3 is 1080 wide in portrait), so we compare the minimum dimension.
|
||
const phoneMaxMinDimension = 1440
|
||
|
||
// DetectDeviceType classifies this host's form factor. Only Linux exposes the
|
||
// signals we read; other platforms return "" and the server applies its
|
||
// conservative 'server' default.
|
||
func DetectDeviceType() string {
|
||
if runtime.GOOS != "linux" {
|
||
return ""
|
||
}
|
||
return detectDeviceTypeFrom("/sys/class/power_supply", "/sys/class/drm", "/sys/class/graphics/fb0/virtual_size")
|
||
}
|
||
|
||
func detectDeviceTypeFrom(powerSupplyDir, drmDir, fbSizePath string) string {
|
||
battery := hasSystemBattery(powerSupplyDir)
|
||
display := hasDisplay(drmDir, fbSizePath)
|
||
|
||
switch {
|
||
case !battery && !display:
|
||
return "server"
|
||
case !battery && display:
|
||
return "desktop"
|
||
case battery && !display:
|
||
return "phone"
|
||
}
|
||
|
||
// battery + display: split phone/tablet on screen size
|
||
if w, h := framebufferSize(fbSizePath); w > 0 && h > 0 {
|
||
if min(w, h) < phoneMaxMinDimension {
|
||
return "phone"
|
||
}
|
||
return "tablet"
|
||
}
|
||
return "phone"
|
||
}
|
||
|
||
// hasSystemBattery scans /sys/class/power_supply for a system battery.
|
||
// Peripheral batteries (bluetooth mice, keyboards) advertise scope=Device
|
||
// and must not classify a desktop as mobile; UPS units report type=UPS.
|
||
func hasSystemBattery(dir string) bool {
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, e := range entries {
|
||
typ, err := os.ReadFile(filepath.Join(dir, e.Name(), "type"))
|
||
if err != nil || strings.TrimSpace(string(typ)) != "Battery" {
|
||
continue
|
||
}
|
||
if scope, err := os.ReadFile(filepath.Join(dir, e.Name(), "scope")); err == nil {
|
||
if strings.TrimSpace(string(scope)) == "Device" {
|
||
continue
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// hasDisplay reports whether a display is attached: any DRM connector in
|
||
// state "connected", falling back to a present framebuffer.
|
||
func hasDisplay(drmDir, fbSizePath string) bool {
|
||
if entries, err := os.ReadDir(drmDir); err == nil {
|
||
for _, e := range entries {
|
||
status, err := os.ReadFile(filepath.Join(drmDir, e.Name(), "status"))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(string(status)) == "connected" {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
if _, err := os.Stat(fbSizePath); err == nil {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// framebufferSize parses /sys/class/graphics/fb0/virtual_size ("1080,2160").
|
||
func framebufferSize(path string) (int, int) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return 0, 0
|
||
}
|
||
parts := strings.SplitN(strings.TrimSpace(string(data)), ",", 2)
|
||
if len(parts) != 2 {
|
||
return 0, 0
|
||
}
|
||
w, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||
h, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||
if errW != nil || errH != nil {
|
||
return 0, 0
|
||
}
|
||
return w, h
|
||
}
|
||
|
||
// ReadDeviceModel returns the hardware model: device-tree on ARM, DMI on x86.
|
||
func ReadDeviceModel() string {
|
||
return readDeviceModelFrom("/proc/device-tree/model", "/sys/class/dmi/id/product_name")
|
||
}
|
||
|
||
func readDeviceModelFrom(dtPath, dmiPath string) string {
|
||
if model, err := os.ReadFile(dtPath); err == nil {
|
||
// device-tree strings are NUL-terminated
|
||
if s := strings.TrimSpace(strings.Trim(string(model), "\x00")); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
if model, err := os.ReadFile(dmiPath); err == nil {
|
||
if s := strings.TrimSpace(string(model)); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// DetectOSDistro returns the distro ID from /etc/os-release ("arch",
|
||
// "fedora", "debian"). Empty on non-Linux or when unreadable.
|
||
func DetectOSDistro() string {
|
||
if runtime.GOOS != "linux" {
|
||
return ""
|
||
}
|
||
return osDistroFrom("/etc/os-release")
|
||
}
|
||
|
||
func osDistroFrom(path string) string {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
if strings.HasPrefix(line, "ID=") {
|
||
return strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||
}
|
||
}
|
||
return ""
|
||
}
|