401 lines
11 KiB
Go
401 lines
11 KiB
Go
// 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> 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)
|
|
}
|