Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/system/device.go
Fimeg ff2f30f47a v0.2.9.3: device classification + ARM support — Pixel 3 lands
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.
2026-07-06 18:21:23 -04:00

166 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 7201440 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 ""
}