The firewall page now manages zones (assign each LAN or tunnel, add and remove zones) and rules with zone, address and port on both sides plus the protocol. router.firewall.zones declares zones without members and rules take sourcePorts. The temperature chart draws every thermal zone and hwmon sensor; the overview defaults to 15 minutes, 1h stays an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
598 lines
15 KiB
Go
598 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const sampleEvery = 5 * time.Second
|
|
|
|
// Collector samples the box every few seconds into the Store and keeps the
|
|
// latest snapshot for the dashboard.
|
|
type Collector struct {
|
|
sys System
|
|
store *Store
|
|
storePath string
|
|
leasesPath string
|
|
|
|
mu sync.Mutex
|
|
snap Snapshot
|
|
|
|
prevIf map[string][2]uint64
|
|
prevCPU [2]uint64 // busy, total
|
|
prevTime time.Time
|
|
}
|
|
|
|
// Snapshot is the state right now.
|
|
type Snapshot struct {
|
|
Time time.Time
|
|
Uplink UplinkStatus
|
|
System SystemStatus
|
|
Pings []PingResult
|
|
}
|
|
|
|
type UplinkStatus struct {
|
|
Device string // pppoe-wan or the WAN interface
|
|
Type string // dhcp or pppoe
|
|
Exists bool
|
|
Connected bool // an IPv4 address and the default route on the uplink
|
|
Reachable bool // at least one public ping target answered
|
|
Checked bool // public ping targets are configured (Reachable means something)
|
|
IPv4 []string
|
|
IPv6 []string
|
|
Gateway string
|
|
Since time.Time
|
|
Events []Event
|
|
}
|
|
|
|
type SystemStatus struct {
|
|
Uptime time.Duration
|
|
Load string
|
|
CPU float64 // percent; NaN until the second sample
|
|
MemUsed uint64
|
|
MemTotal uint64
|
|
Temperature float64 // °C, the hottest sensor; NaN if there is none
|
|
Sensors []Sensor
|
|
DiskUsed uint64
|
|
DiskTotal uint64
|
|
}
|
|
|
|
type PingResult struct {
|
|
Target string // as configured ("gateway" or an address)
|
|
Address string // what was pinged
|
|
RTT time.Duration
|
|
OK bool
|
|
}
|
|
|
|
func newCollector(sys System, leasesPath string) *Collector {
|
|
path := filepath.Join(sys.StateDir, "webui", "metrics.gob")
|
|
store, err := loadStore(path)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
log.Printf("metric history: %v (starting empty)", err)
|
|
}
|
|
return &Collector{sys: sys, store: store, storePath: path, leasesPath: leasesPath}
|
|
}
|
|
|
|
func (c *Collector) Snapshot() Snapshot {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.snap
|
|
}
|
|
|
|
func (c *Collector) Save() {
|
|
if err := os.MkdirAll(filepath.Dir(c.storePath), 0o700); err != nil {
|
|
log.Printf("saving metric history: %v", err)
|
|
return
|
|
}
|
|
if err := c.store.Save(c.storePath); err != nil {
|
|
log.Printf("saving metric history: %v", err)
|
|
}
|
|
}
|
|
|
|
// Run samples until ctx ends, saving the history every 15 minutes and at the end.
|
|
func (c *Collector) Run(ctx context.Context) {
|
|
tick := time.NewTicker(sampleEvery)
|
|
defer tick.Stop()
|
|
save := time.NewTicker(15 * time.Minute)
|
|
defer save.Stop()
|
|
c.sample(time.Now())
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
c.Save()
|
|
return
|
|
case <-save.C:
|
|
c.Save()
|
|
case now := <-tick.C:
|
|
c.sample(now)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Collector) sample(now time.Time) {
|
|
s, _ := loadSettings(c.sys.settingsPath())
|
|
f := s.form()
|
|
prefs := c.sys.loadPrefs()
|
|
|
|
// Pings run while the rest is sampled.
|
|
var pings []PingResult
|
|
done := make(chan struct{})
|
|
go func() {
|
|
pings = pingAll(prefs.PingTargets, 2*time.Second)
|
|
close(done)
|
|
}()
|
|
|
|
dt := now.Sub(c.prevTime).Seconds()
|
|
counters := interfaceCounters()
|
|
if c.prevIf != nil && dt > 0 {
|
|
for dev, cur := range counters {
|
|
prev, ok := c.prevIf[dev]
|
|
if !ok || cur[0] < prev[0] || cur[1] < prev[1] {
|
|
continue // new device or counters reset (a PPP reconnect)
|
|
}
|
|
c.store.Add("if:"+dev+":rx", now, float64(cur[0]-prev[0])/dt)
|
|
c.store.Add("if:"+dev+":tx", now, float64(cur[1]-prev[1])/dt)
|
|
}
|
|
}
|
|
c.prevIf, c.prevTime = counters, now
|
|
|
|
sys := systemStatus()
|
|
busy, total := cpuTimes()
|
|
sys.CPU = math.NaN()
|
|
if c.prevCPU[1] != 0 && total > c.prevCPU[1] {
|
|
sys.CPU = 100 * float64(busy-c.prevCPU[0]) / float64(total-c.prevCPU[1])
|
|
}
|
|
c.prevCPU = [2]uint64{busy, total}
|
|
c.store.Add("sys:cpu", now, sys.CPU)
|
|
if sys.MemTotal > 0 {
|
|
c.store.Add("sys:mem", now, 100*float64(sys.MemUsed)/float64(sys.MemTotal))
|
|
}
|
|
c.store.Add("sys:temp", now, sys.Temperature)
|
|
for _, t := range sys.Sensors {
|
|
c.store.Add("temp:"+t.Key, now, t.Temp)
|
|
}
|
|
|
|
up := uplinkStatus(f.WAN)
|
|
<-done
|
|
for _, p := range pings {
|
|
loss := 100.0
|
|
if p.OK {
|
|
loss = 0
|
|
c.store.Add("ping:"+p.Target+":rtt", now, float64(p.RTT.Microseconds())/1000)
|
|
}
|
|
c.store.Add("ping:"+p.Target+":loss", now, loss)
|
|
if p.Target != "gateway" {
|
|
up.Checked = true
|
|
up.Reachable = up.Reachable || p.OK
|
|
}
|
|
}
|
|
detail := strings.Join(up.IPv4, ", ")
|
|
if !up.Connected {
|
|
detail = ""
|
|
}
|
|
c.store.SetConnected(up.Connected, now, detail)
|
|
_, up.Since, up.Events = c.store.Uplink()
|
|
|
|
c.mu.Lock()
|
|
c.snap = Snapshot{Time: now, Uplink: up, System: sys, Pings: pings}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// interfaceCounters reads rx/tx bytes of every interface but lo.
|
|
func interfaceCounters() map[string][2]uint64 {
|
|
out := map[string][2]uint64{}
|
|
entries, _ := os.ReadDir("/sys/class/net")
|
|
for _, e := range entries {
|
|
dev := e.Name()
|
|
if dev == "lo" {
|
|
continue
|
|
}
|
|
rx, err1 := readUint(filepath.Join("/sys/class/net", dev, "statistics/rx_bytes"))
|
|
tx, err2 := readUint(filepath.Join("/sys/class/net", dev, "statistics/tx_bytes"))
|
|
if err1 == nil && err2 == nil {
|
|
out[dev] = [2]uint64{rx, tx}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readUint(path string) (uint64, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
|
}
|
|
|
|
func cpuTimes() (busy, total uint64) {
|
|
f, err := os.Open("/proc/stat")
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
defer f.Close()
|
|
line, _ := bufio.NewReader(f).ReadString('\n')
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 5 || fields[0] != "cpu" {
|
|
return 0, 0
|
|
}
|
|
for i, v := range fields[1:] {
|
|
n, _ := strconv.ParseUint(v, 10, 64)
|
|
total += n
|
|
if i != 3 && i != 4 { // idle, iowait
|
|
busy += n
|
|
}
|
|
}
|
|
return busy, total
|
|
}
|
|
|
|
func systemStatus() SystemStatus {
|
|
s := SystemStatus{Sensors: sensors()}
|
|
s.Temperature = hottest(s.Sensors)
|
|
if data, err := os.ReadFile("/proc/uptime"); err == nil {
|
|
if f := strings.Fields(string(data)); len(f) > 0 {
|
|
secs, _ := strconv.ParseFloat(f[0], 64)
|
|
s.Uptime = time.Duration(secs) * time.Second
|
|
}
|
|
}
|
|
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
|
if f := strings.Fields(string(data)); len(f) >= 3 {
|
|
s.Load = strings.Join(f[:3], " ")
|
|
}
|
|
}
|
|
if f, err := os.Open("/proc/meminfo"); err == nil {
|
|
mem := map[string]uint64{}
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
k, v, ok := strings.Cut(sc.Text(), ":")
|
|
if ok {
|
|
n, _ := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(v), " kB"), 10, 64)
|
|
mem[k] = n * 1024
|
|
}
|
|
}
|
|
f.Close()
|
|
s.MemTotal = mem["MemTotal"]
|
|
if mem["MemAvailable"] <= s.MemTotal {
|
|
s.MemUsed = s.MemTotal - mem["MemAvailable"]
|
|
}
|
|
}
|
|
var st syscall.Statfs_t
|
|
if syscall.Statfs("/", &st) == nil {
|
|
s.DiskTotal = st.Blocks * uint64(st.Bsize)
|
|
s.DiskUsed = (st.Blocks - st.Bfree) * uint64(st.Bsize)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Sensor is one temperature the machine reports.
|
|
type Sensor struct {
|
|
Key string // stable name of its series
|
|
Label string
|
|
Temp float64 // °C
|
|
}
|
|
|
|
// sensors lists the thermal zones and hwmon temperature inputs, sorted by
|
|
// label. Two sensors with the same name get a number.
|
|
func sensors() []Sensor {
|
|
return readSensors("/sys/class/thermal", "/sys/class/hwmon")
|
|
}
|
|
|
|
func readSensors(thermalDir, hwmonDir string) []Sensor {
|
|
var out []Sensor
|
|
add := func(label, file string) {
|
|
if v, err := readUint(file); err == nil && v > 0 && v < 150000 {
|
|
out = append(out, Sensor{Label: label, Temp: float64(v) / 1000})
|
|
}
|
|
}
|
|
text := func(file string) string {
|
|
data, _ := os.ReadFile(file)
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
zones, _ := filepath.Glob(filepath.Join(thermalDir, "thermal_zone*"))
|
|
for _, z := range zones {
|
|
label := text(filepath.Join(z, "type"))
|
|
if label == "" {
|
|
label = filepath.Base(z)
|
|
}
|
|
add(label, filepath.Join(z, "temp"))
|
|
}
|
|
inputs, _ := filepath.Glob(filepath.Join(hwmonDir, "hwmon*", "temp*_input"))
|
|
for _, in := range inputs {
|
|
dir, base := filepath.Split(in)
|
|
chip := text(filepath.Join(dir, "name"))
|
|
if chip == "" {
|
|
chip = filepath.Base(dir)
|
|
}
|
|
name := strings.TrimSuffix(base, "_input")
|
|
sub := text(filepath.Join(dir, name+"_label"))
|
|
if sub == "" {
|
|
sub = name
|
|
}
|
|
add(chip+" "+sub, in)
|
|
}
|
|
slices.SortStableFunc(out, func(a, b Sensor) int { return strings.Compare(a.Label, b.Label) })
|
|
count := map[string]int{}
|
|
for i := range out {
|
|
base := out[i].Label
|
|
count[base]++
|
|
if n := count[base]; n > 1 {
|
|
out[i].Label = fmt.Sprintf("%s %d", base, n)
|
|
}
|
|
out[i].Key = out[i].Label
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hottest(sensors []Sensor) float64 {
|
|
h := math.NaN()
|
|
for _, t := range sensors {
|
|
if math.IsNaN(h) || t.Temp > h {
|
|
h = t.Temp
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// uplinkDevice is where the router's IPv4 internet traffic leaves.
|
|
func uplinkDevice(wan WANForm) string {
|
|
if wan.Type == "pppoe" {
|
|
return "pppoe-wan"
|
|
}
|
|
return wan.Interface
|
|
}
|
|
|
|
func uplinkStatus(wan WANForm) UplinkStatus {
|
|
u := UplinkStatus{Device: uplinkDevice(wan), Type: wan.Type}
|
|
iface, err := net.InterfaceByName(u.Device)
|
|
if err != nil {
|
|
return u
|
|
}
|
|
u.Exists = true
|
|
addrs, _ := iface.Addrs()
|
|
for _, a := range addrs {
|
|
ipnet, ok := a.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ip4 := ipnet.IP.To4(); ip4 != nil {
|
|
u.IPv4 = append(u.IPv4, ip4.String())
|
|
} else if ipnet.IP.IsGlobalUnicast() {
|
|
u.IPv6 = append(u.IPv6, ipnet.IP.String())
|
|
}
|
|
}
|
|
gw, dev := defaultRoute()
|
|
u.Connected = len(u.IPv4) > 0 && dev == u.Device
|
|
if u.Connected {
|
|
u.Gateway = gw
|
|
}
|
|
return u
|
|
}
|
|
|
|
// defaultRoute returns the IPv4 default route's gateway (for a point-to-point
|
|
// uplink: the peer) and device.
|
|
func defaultRoute() (gateway, dev string) {
|
|
out, err := command("ip", "-j", "-4", "route", "show", "default")
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
var routes []struct {
|
|
Gateway string `json:"gateway"`
|
|
Dev string `json:"dev"`
|
|
}
|
|
if json.Unmarshal([]byte(out), &routes) != nil || len(routes) == 0 {
|
|
return "", ""
|
|
}
|
|
gateway, dev = routes[0].Gateway, routes[0].Dev
|
|
if gateway == "" && dev != "" {
|
|
out, err := command("ip", "-j", "-4", "addr", "show", "dev", dev)
|
|
if err != nil {
|
|
return "", dev
|
|
}
|
|
var links []struct {
|
|
AddrInfo []struct {
|
|
Address string `json:"address"` // the peer, on point-to-point links
|
|
} `json:"addr_info"`
|
|
}
|
|
if json.Unmarshal([]byte(out), &links) == nil && len(links) > 0 && len(links[0].AddrInfo) > 0 {
|
|
gateway = links[0].AddrInfo[0].Address
|
|
}
|
|
}
|
|
return gateway, dev
|
|
}
|
|
|
|
// pingAll pings every target once, in parallel. "gateway" is the default
|
|
// route's gateway (the ISP's end of the line).
|
|
func pingAll(targets []string, timeout time.Duration) []PingResult {
|
|
out := make([]PingResult, len(targets))
|
|
var wg sync.WaitGroup
|
|
for i, t := range targets {
|
|
out[i] = PingResult{Target: t, Address: t}
|
|
if t == "gateway" {
|
|
gw, _ := defaultRoute()
|
|
out[i].Address = gw
|
|
}
|
|
addr, err := netip.ParseAddr(out[i].Address)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
wg.Go(func() {
|
|
rtt, err := ping(addr, timeout)
|
|
out[i].RTT, out[i].OK = rtt, err == nil
|
|
})
|
|
}
|
|
wg.Wait()
|
|
return out
|
|
}
|
|
|
|
// ping sends one ICMP echo request over a raw socket (the UI runs as root).
|
|
func ping(addr netip.Addr, timeout time.Duration) (time.Duration, error) {
|
|
network, request, reply := "ip4:icmp", byte(8), byte(0)
|
|
if addr.Is6() && !addr.Is4In6() {
|
|
network, request, reply = "ip6:ipv6-icmp", 128, 129
|
|
}
|
|
conn, err := net.ListenPacket(network, "")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer conn.Close()
|
|
id := uint16(rand.Uint32())
|
|
msg := make([]byte, 16)
|
|
msg[0] = request
|
|
binary.BigEndian.PutUint16(msg[4:], id)
|
|
binary.BigEndian.PutUint16(msg[6:], 1)
|
|
if request == 8 {
|
|
// The kernel fills in the ICMPv6 checksum, not ICMPv4's.
|
|
binary.BigEndian.PutUint16(msg[2:], checksum(msg))
|
|
}
|
|
start := time.Now()
|
|
conn.SetDeadline(start.Add(timeout))
|
|
if _, err := conn.WriteTo(msg, &net.IPAddr{IP: addr.AsSlice()}); err != nil {
|
|
return 0, err
|
|
}
|
|
buf := make([]byte, 1500)
|
|
for {
|
|
n, from, err := conn.ReadFrom(buf)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// Raw sockets see every ICMP message; ours carries our ID.
|
|
if n >= 8 && buf[0] == reply && binary.BigEndian.Uint16(buf[4:]) == id {
|
|
if ip, ok := from.(*net.IPAddr); ok && ip.IP.Equal(addr.AsSlice()) {
|
|
return time.Since(start), nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func checksum(b []byte) uint16 {
|
|
var sum uint32
|
|
for i := 0; i+1 < len(b); i += 2 {
|
|
sum += uint32(b[i])<<8 | uint32(b[i+1])
|
|
}
|
|
if len(b)%2 == 1 {
|
|
sum += uint32(b[len(b)-1]) << 8
|
|
}
|
|
for sum>>16 != 0 {
|
|
sum = sum&0xffff + sum>>16
|
|
}
|
|
return ^uint16(sum)
|
|
}
|
|
|
|
// Lease is an active DHCPv4 lease.
|
|
type Lease struct {
|
|
Address string
|
|
MAC string
|
|
Hostname string
|
|
Start time.Time // when it was handed out or last renewed
|
|
Expires time.Time
|
|
LAN string
|
|
}
|
|
|
|
// readLeases reads Kea's memfile: a CSV log in which the last line for an
|
|
// address wins. The previous file of a lease file cleanup comes first.
|
|
func readLeases(path string, lans []LANForm, now time.Time) []Lease {
|
|
latest := map[string]Lease{}
|
|
for _, p := range []string{path + ".completed", path + ".2", path} {
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
parseLeases(f, latest, now)
|
|
f.Close()
|
|
}
|
|
var out []Lease
|
|
for _, l := range latest {
|
|
if l.Expires.After(now) {
|
|
l.LAN = lanOf(l.Address, lans)
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
slices.SortFunc(out, func(a, b Lease) int {
|
|
x, _ := netip.ParseAddr(a.Address)
|
|
y, _ := netip.ParseAddr(b.Address)
|
|
return x.Compare(y)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func parseLeases(r io.Reader, latest map[string]Lease, now time.Time) {
|
|
cr := csv.NewReader(r)
|
|
cr.FieldsPerRecord = -1
|
|
header, err := cr.Read()
|
|
if err != nil {
|
|
return
|
|
}
|
|
col := map[string]int{}
|
|
for i, h := range header {
|
|
col[h] = i
|
|
}
|
|
get := func(rec []string, name string) string {
|
|
if i, ok := col[name]; ok && i < len(rec) {
|
|
return rec[i]
|
|
}
|
|
return ""
|
|
}
|
|
for {
|
|
rec, err := cr.Read()
|
|
if err == io.EOF {
|
|
return
|
|
}
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if len(rec) > 0 && rec[0] == header[0] {
|
|
continue // a header repeated by a cleanup
|
|
}
|
|
addr := get(rec, "address")
|
|
expire, _ := strconv.ParseInt(get(rec, "expire"), 10, 64)
|
|
lifetime, _ := strconv.ParseInt(get(rec, "valid_lifetime"), 10, 64)
|
|
state := get(rec, "state")
|
|
l := Lease{
|
|
Address: addr,
|
|
MAC: get(rec, "hwaddr"),
|
|
Hostname: strings.ReplaceAll(get(rec, "hostname"), ",", ","),
|
|
Expires: time.Unix(expire, 0),
|
|
Start: time.Unix(expire-lifetime, 0),
|
|
}
|
|
if lifetime == 0 || (state != "" && state != "0") {
|
|
l.Expires = time.Time{} // released, declined or reclaimed
|
|
}
|
|
latest[addr] = l
|
|
}
|
|
}
|
|
|
|
func lanOf(addr string, lans []LANForm) string {
|
|
a, err := netip.ParseAddr(addr)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, l := range lans {
|
|
prefix := 24
|
|
if p, err := strconv.Atoi(l.Prefix); err == nil {
|
|
prefix = p
|
|
}
|
|
if pfx, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", l.Address, prefix)); err == nil && pfx.Masked().Contains(a) {
|
|
return l.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|