TLS-only HTTP API with Argon2id auth, setup token, CSRF, TOTP, login throttling, management source allow-list; network apply via helper; capture endpoints; SSE events; crash recovery of interrupted segments; systemd notify/watchdog.
260 lines
7.1 KiB
Go
260 lines
7.1 KiB
Go
// Package sysinfo discovers interfaces and reads host health without privileges.
|
|
package sysinfo
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type Interface struct {
|
|
Name string `json:"name"`
|
|
MAC string `json:"mac"`
|
|
Addrs []string `json:"addrs"`
|
|
Up bool `json:"up"`
|
|
Carrier bool `json:"carrier"`
|
|
Wireless bool `json:"wireless"`
|
|
Driver string `json:"driver"`
|
|
DefaultGW string `json:"default_gateway,omitempty"`
|
|
SpeedMbps int `json:"speed_mbps,omitempty"`
|
|
Virtual bool `json:"virtual"`
|
|
RxDropped uint64 `json:"rx_dropped"`
|
|
}
|
|
|
|
func readTrim(p string) string {
|
|
b, _ := os.ReadFile(p)
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
// DefaultRoutes maps interface -> IPv4 default gateway from /proc/net/route.
|
|
func DefaultRoutes() map[string]string {
|
|
out := map[string]string{}
|
|
f, err := os.Open("/proc/net/route")
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer f.Close()
|
|
sc := bufio.NewScanner(f)
|
|
sc.Scan()
|
|
for sc.Scan() {
|
|
x := strings.Fields(sc.Text())
|
|
if len(x) < 3 || x[1] != "00000000" {
|
|
continue
|
|
}
|
|
b, err := hex.DecodeString(x[2])
|
|
if err != nil || len(b) != 4 {
|
|
continue
|
|
}
|
|
if _, ok := out[x[0]]; !ok {
|
|
out[x[0]] = net.IPv4(b[3], b[2], b[1], b[0]).String()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func Interfaces() []Interface {
|
|
ifs, _ := net.Interfaces()
|
|
routes := DefaultRoutes()
|
|
var out []Interface
|
|
for _, i := range ifs {
|
|
if i.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
base := "/sys/class/net/" + i.Name
|
|
x := Interface{Name: i.Name, MAC: i.HardwareAddr.String(), Up: i.Flags&net.FlagUp != 0, DefaultGW: routes[i.Name]}
|
|
x.Carrier = readTrim(base+"/carrier") == "1"
|
|
_, err := os.Stat(base + "/wireless")
|
|
x.Wireless = err == nil
|
|
if _, err := os.Stat(base + "/phy80211"); err == nil {
|
|
x.Wireless = true
|
|
}
|
|
if d, err := os.Readlink(base + "/device/driver"); err == nil {
|
|
x.Driver = filepath.Base(d)
|
|
}
|
|
_, err = os.Stat(base + "/device")
|
|
x.Virtual = err != nil
|
|
x.SpeedMbps, _ = strconv.Atoi(readTrim(base + "/speed"))
|
|
x.RxDropped, _ = strconv.ParseUint(readTrim(base+"/statistics/rx_dropped"), 10, 64)
|
|
addrs, _ := i.Addrs()
|
|
for _, a := range addrs {
|
|
x.Addrs = append(x.Addrs, a.String())
|
|
}
|
|
out = append(out, x)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type WifiCaps struct {
|
|
Phy string `json:"phy"`
|
|
AP bool `json:"ap_mode"`
|
|
Bands []string `json:"bands"` // "2.4GHz", "5GHz"
|
|
Raw string `json:"-"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// WifiCapabilities parses `iw list` (runs unprivileged).
|
|
func WifiCapabilities() []WifiCaps {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "iw", "list").Output()
|
|
if err != nil {
|
|
return []WifiCaps{{Error: "iw list failed: " + err.Error()}}
|
|
}
|
|
return ParseIWList(string(out))
|
|
}
|
|
|
|
func ParseIWList(s string) []WifiCaps {
|
|
var res []WifiCaps
|
|
var cur *WifiCaps
|
|
inModes := false
|
|
for _, line := range strings.Split(s, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Wiphy ") {
|
|
res = append(res, WifiCaps{Phy: strings.TrimPrefix(line, "Wiphy ")})
|
|
cur = &res[len(res)-1]
|
|
inModes = false
|
|
continue
|
|
}
|
|
if cur == nil {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(t, "Supported interface modes"):
|
|
inModes = true
|
|
continue
|
|
case strings.HasPrefix(t, "Band 1:"):
|
|
cur.Bands = append(cur.Bands, "2.4GHz")
|
|
case strings.HasPrefix(t, "Band 2:"):
|
|
cur.Bands = append(cur.Bands, "5GHz")
|
|
}
|
|
if inModes {
|
|
if strings.HasPrefix(t, "* ") {
|
|
if t == "* AP" {
|
|
cur.AP = true
|
|
}
|
|
} else {
|
|
inModes = false
|
|
}
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
type Health struct {
|
|
CPUPercent float64 `json:"cpu_percent"`
|
|
MemTotal uint64 `json:"mem_total"`
|
|
MemAvail uint64 `json:"mem_available"`
|
|
TempC *float64 `json:"temp_c,omitempty"`
|
|
Throttled string `json:"throttled,omitempty"` // vcgencmd get_throttled, raw
|
|
ThrottleBits []string `json:"throttle_flags,omitempty"`
|
|
Load string `json:"load"`
|
|
UptimeS float64 `json:"uptime_s"`
|
|
Disks map[string]Disk `json:"disks"`
|
|
ClockSync string `json:"clock_synchronised"`
|
|
OS string `json:"os"`
|
|
Kernel string `json:"kernel"`
|
|
Model string `json:"model"`
|
|
NMVersion string `json:"networkmanager"`
|
|
}
|
|
|
|
type Disk struct {
|
|
Total uint64 `json:"total"`
|
|
Free uint64 `json:"free"`
|
|
}
|
|
|
|
func cpuSample() (idle, total uint64) {
|
|
f := strings.Fields(strings.SplitN(readTrim("/proc/stat"), "\n", 2)[0])
|
|
for i, v := range f[1:] {
|
|
n, _ := strconv.ParseUint(v, 10, 64)
|
|
total += n
|
|
if i == 3 || i == 4 {
|
|
idle += n
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func cmdOut(name string, args ...string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
b, err := exec.CommandContext(ctx, name, args...).Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
// throttle bit meanings from the Raspberry Pi firmware documentation.
|
|
var throttleNames = map[int]string{0: "under-voltage now", 1: "ARM frequency capped now", 2: "throttled now", 3: "soft temperature limit now",
|
|
16: "under-voltage has occurred", 17: "frequency capping has occurred", 18: "throttling has occurred", 19: "soft temperature limit has occurred"}
|
|
|
|
func ReadHealth(paths map[string]string) Health {
|
|
var h Health
|
|
i1, t1 := cpuSample()
|
|
time.Sleep(200 * time.Millisecond)
|
|
i2, t2 := cpuSample()
|
|
if t2 > t1 {
|
|
h.CPUPercent = 100 * (1 - float64(i2-i1)/float64(t2-t1))
|
|
}
|
|
for _, l := range strings.Split(readTrim("/proc/meminfo"), "\n") {
|
|
f := strings.Fields(l)
|
|
if len(f) < 2 {
|
|
continue
|
|
}
|
|
v, _ := strconv.ParseUint(f[1], 10, 64)
|
|
switch f[0] {
|
|
case "MemTotal:":
|
|
h.MemTotal = v * 1024
|
|
case "MemAvailable:":
|
|
h.MemAvail = v * 1024
|
|
}
|
|
}
|
|
if t, err := strconv.ParseFloat(readTrim("/sys/class/thermal/thermal_zone0/temp"), 64); err == nil {
|
|
c := t / 1000
|
|
h.TempC = &c
|
|
}
|
|
if s := cmdOut("vcgencmd", "get_throttled"); strings.HasPrefix(s, "throttled=") {
|
|
h.Throttled = s
|
|
if v, err := strconv.ParseUint(strings.TrimPrefix(s, "throttled="), 0, 32); err == nil {
|
|
for bit, name := range throttleNames {
|
|
if v&(1<<bit) != 0 {
|
|
h.ThrottleBits = append(h.ThrottleBits, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
h.Load = readTrim("/proc/loadavg")
|
|
up := strings.Fields(readTrim("/proc/uptime"))
|
|
if len(up) > 0 {
|
|
h.UptimeS, _ = strconv.ParseFloat(up[0], 64)
|
|
}
|
|
h.Disks = map[string]Disk{}
|
|
for name, p := range paths {
|
|
var st syscall.Statfs_t
|
|
if syscall.Statfs(p, &st) == nil {
|
|
h.Disks[name] = Disk{Total: st.Blocks * uint64(st.Bsize), Free: st.Bavail * uint64(st.Bsize)}
|
|
}
|
|
}
|
|
h.ClockSync = cmdOut("timedatectl", "show", "-p", "NTPSynchronized", "--value")
|
|
if h.ClockSync == "" {
|
|
h.ClockSync = "unknown"
|
|
}
|
|
for _, l := range strings.Split(readTrim("/etc/os-release"), "\n") {
|
|
if v, ok := strings.CutPrefix(l, "PRETTY_NAME="); ok {
|
|
h.OS = strings.Trim(v, `"`)
|
|
}
|
|
}
|
|
h.Kernel = readTrim("/proc/sys/kernel/osrelease")
|
|
h.Model = strings.TrimRight(readTrim("/proc/device-tree/model"), "\x00")
|
|
h.NMVersion = cmdOut("nmcli", "--version")
|
|
return h
|
|
}
|