// fleet-hostapi — read-only workstation host-info agent for the kiosk. // // Implements the contract in HOST-API.md: a tiny HTTP service on the diskless // workstation that reports facts about the machine the kiosk renders on. The // kiosk SPA (served from the boot server, a different origin) fetches // // GET http://localhost:9099/v1/host -> application/json // // Design constraints (see CLAUDE.md invariants): // - LOOPBACK ONLY. Never bind a routable address — this leaks host facts and // must never reach the boot LAN. // - READ ONLY. No endpoint mutates anything; the agent only reads /proc, /sys, // /etc/os-release, and a build-time facts file. // - NO PER-HOST STATE baked into the image. Everything host-specific (kernel, // hostname, IP, MAC, board, uptime) is read at RUNTIME. The only baked facts // are image-generic (image version + weston/chromium versions), written to a // facts file by the build provisioner — same for every host of a release. // - Stdlib only -> reproducible build (the verity hash is a security property). // // CORS + Private Network Access: the SPA is cross-origin AND the fetch is a // Chromium "private network" request, so every response (incl. the OPTIONS // preflight) carries Access-Control-Allow-Private-Network: true. package main import ( "bufio" "encoding/json" "flag" "log" "math" "net" "net/http" "os" "runtime" "strconv" "strings" "syscall" "time" ) // agentVersion is overridable at build time via -ldflags "-X main.agentVersion=…". var agentVersion = "1.0.0" // ---- response shape (HOST-API.md) — all fields optional, omit what's unknown -- type osInfo struct { Name string `json:"name,omitempty"` Version string `json:"version,omitempty"` Kernel string `json:"kernel,omitempty"` Image string `json:"image,omitempty"` } type hwInfo struct { CPU string `json:"cpu,omitempty"` Cores int `json:"cores,omitempty"` MemGB int `json:"memGB,omitempty"` GPU string `json:"gpu,omitempty"` Board string `json:"board,omitempty"` } type netInfo struct { Hostname string `json:"hostname,omitempty"` IP string `json:"ip,omitempty"` MAC string `json:"mac,omitempty"` } type kioskInfo struct { Uptime string `json:"uptime,omitempty"` Weston string `json:"weston,omitempty"` Chromium string `json:"chromium,omitempty"` } type agentInfo struct { Version string `json:"version,omitempty"` } type hostResponse struct { OS osInfo `json:"os"` Hardware hwInfo `json:"hardware"` Net netInfo `json:"net"` Kiosk kioskInfo `json:"kiosk"` Agent agentInfo `json:"agent"` } // buildFacts is the image-generic, build-time facts file (written by the Packer // provisioner). Distinct from anything per-host. type buildFacts struct { Image string `json:"image"` Weston string `json:"weston"` Chromium string `json:"chromium"` } func main() { addr := flag.String("addr", env("FLEET_HOSTAPI_ADDR", "127.0.0.1:9099"), "listen address (MUST be loopback)") origin := flag.String("allow-origin", env("FLEET_HOSTAPI_ALLOW_ORIGIN", "*"), "Access-Control-Allow-Origin value") factsPath := flag.String("facts", env("FLEET_HOSTAPI_FACTS", "/etc/fleet-hostinfo.json"), "build-time facts file") flag.Parse() // Refuse to bind anything that isn't loopback — a hard guardrail against a // misconfiguration exposing host facts on the boot LAN. if host, _, err := net.SplitHostPort(*addr); err != nil || !isLoopback(host) { log.Fatalf("refusing to listen on %q: bind address must be loopback (127.0.0.1/::1)", *addr) } facts := loadFacts(*factsPath) mux := http.NewServeMux() mux.HandleFunc("/v1/host", func(w http.ResponseWriter, r *http.Request) { cors(w, *origin) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(collect(facts)) }) // Liveness for the unit / debugging (no host facts). mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("ok\n")) }) srv := &http.Server{ Addr: *addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } log.Printf("fleet-hostapi %s listening on %s (loopback, read-only)", agentVersion, *addr) log.Fatal(srv.ListenAndServe()) } func cors(w http.ResponseWriter, origin string) { // Private Network Access: the kiosk page (boot-server origin, an insecure/ // public context) fetching localhost is a "private network" request — the // preflight needs this header or Chromium blocks it. See HOST-API.md. w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Private-Network", "true") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Vary", "Origin") } // collect gathers all host facts. Each source is best-effort: a failed read // leaves its field empty (the SPA renders "—" for missing fields). func collect(f buildFacts) hostResponse { var resp hostResponse // os rel := parseKV("/etc/os-release") resp.OS.Name = firstNonEmpty(rel["NAME"], rel["ID"]) resp.OS.Version = firstNonEmpty(rel["VERSION_ID"], rel["VERSION"]) resp.OS.Kernel = kernelRelease() resp.OS.Image = f.Image // hardware resp.Hardware.CPU = cpuModel() resp.Hardware.Cores = runtime.NumCPU() resp.Hardware.MemGB = memGB() resp.Hardware.GPU = gpuModel() resp.Hardware.Board = boardModel() // net resp.Net.Hostname, _ = os.Hostname() resp.Net.IP, resp.Net.MAC = primaryIfaceAddr() // kiosk resp.Kiosk.Uptime = uptimeHuman() resp.Kiosk.Weston = f.Weston resp.Kiosk.Chromium = f.Chromium resp.Agent.Version = agentVersion return resp } // ---- collectors ----------------------------------------------------------- func kernelRelease() string { var u syscall.Utsname if err := syscall.Uname(&u); err != nil { return "" } return charsToString(u.Release[:]) } func cpuModel() string { f, err := os.Open("/proc/cpuinfo") if err != nil { return "" } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { line := sc.Text() // x86: "model name". arm64: "Hardware"/"Processor" if present. for _, key := range []string{"model name", "Hardware", "Processor"} { if strings.HasPrefix(line, key) { if _, v, ok := strings.Cut(line, ":"); ok { return strings.TrimSpace(v) } } } } _ = sc.Err() // best-effort read; empty result on any error return "" } func memGB() int { for _, line := range readLines("/proc/meminfo") { if strings.HasPrefix(line, "MemTotal:") { fields := strings.Fields(line) // MemTotal: kB if len(fields) >= 2 { if kb, err := strconv.ParseFloat(fields[1], 64); err == nil { return int(math.Round(kb / 1024.0 / 1024.0)) } } } } return 0 } // boardModel: vendor + product from DMI (world-readable sysfs — NOT product_serial, // which is root-only and per-host; we never read it). func boardModel() string { vendor := strings.TrimSpace(readFile("/sys/class/dmi/id/sys_vendor")) product := strings.TrimSpace(readFile("/sys/class/dmi/id/product_name")) if vendor == "" && product == "" { vendor = strings.TrimSpace(readFile("/sys/class/dmi/id/board_vendor")) product = strings.TrimSpace(readFile("/sys/class/dmi/id/board_name")) } return strings.TrimSpace(strings.TrimSpace(vendor + " " + product)) } // gpuModel: best-effort from the first DRM card's PCI ids. No PCI-ID database — // we map the few known vendors and otherwise emit the raw ids. Often empty on a // headless/virtio build VM; that's fine (optional field). func gpuModel() string { for _, card := range []string{"card0", "card1"} { base := "/sys/class/drm/" + card + "/device/" vid := strings.TrimSpace(readFile(base + "vendor")) did := strings.TrimSpace(readFile(base + "device")) if vid == "" { continue } name := map[string]string{ "0x8086": "Intel", "0x1002": "AMD", "0x10de": "NVIDIA", "0x1af4": "Red Hat (virtio-gpu)", "0x1234": "QEMU", "0x1a03": "ASPEED", }[strings.ToLower(vid)] ids := strings.TrimPrefix(strings.ToLower(vid), "0x") if did != "" { ids += ":" + strings.TrimPrefix(strings.ToLower(did), "0x") } if name != "" { return name + " [" + ids + "]" } return "[" + ids + "]" } return "" } // primaryIfaceAddr returns the preferred outbound IPv4 + the MAC of the // interface that owns it. Uses a UDP "connect" (no packet is sent) to let the // kernel pick the route's source address. func primaryIfaceAddr() (ip string, mac string) { c, err := net.Dial("udp", "192.0.2.1:9") // TEST-NET-1, never reachable; no traffic on UDP connect if err != nil { return "", "" } defer c.Close() la, ok := c.LocalAddr().(*net.UDPAddr) if !ok || la.IP == nil { return "", "" } ip = la.IP.String() // find the interface carrying this IP -> its MAC ifaces, err := net.Interfaces() if err != nil { return ip, "" } for _, ifc := range ifaces { addrs, _ := ifc.Addrs() for _, a := range addrs { if n, ok := a.(*net.IPNet); ok && n.IP.Equal(la.IP) { return ip, ifc.HardwareAddr.String() } } } return ip, "" } // uptimeHuman formats system uptime as "3h 12m". On a kiosk appliance the system // boots straight into the kiosk, so system uptime ≈ kiosk-session uptime — // reported here to avoid coupling to (and exec'ing) systemd for the unit time. func uptimeHuman() string { var si syscall.Sysinfo_t if err := syscall.Sysinfo(&si); err != nil { return "" } d := time.Duration(si.Uptime) * time.Second days := int(d.Hours()) / 24 hours := int(d.Hours()) % 24 mins := int(d.Minutes()) % 60 switch { case days > 0: return strconv.Itoa(days) + "d " + strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m" case hours > 0: return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m" default: return strconv.Itoa(mins) + "m" } } // ---- helpers -------------------------------------------------------------- func loadFacts(path string) buildFacts { var f buildFacts b, err := os.ReadFile(path) if err != nil { return f // file absent at runtime -> image/weston/chromium just omitted } _ = json.Unmarshal(b, &f) return f } func env(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func isLoopback(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func parseKV(path string) map[string]string { m := map[string]string{} for _, line := range readLines(path) { k, v, ok := strings.Cut(line, "=") if !ok { continue } m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`) } return m } func readLines(path string) []string { b := readFile(path) if b == "" { return nil } return strings.Split(strings.TrimRight(b, "\n"), "\n") } func readFile(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } return string(b) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } // charsToString converts a NUL-terminated Utsname field (int8 or uint8 across // arches) to a Go string. func charsToString(ca []int8) string { b := make([]byte, 0, len(ca)) for _, c := range ca { if c == 0 { break } b = append(b, byte(c)) } return string(b) }