Add a dashboard and backup download to the web UI

Metric history (5s to 1h tiers, persisted), uplink status, ping targets,
DHCP leases, and a tar.gz backup of the state directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent e684ddb06c
commit 2a111d012b
13 changed files with 1835 additions and 37 deletions
+540
View File
@@ -0,0 +1,540 @@
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, NaN if no 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)
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{Temperature: temperature()}
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
}
// temperature is the hottest thermal zone, or hwmon sensor if there is none.
func temperature() float64 {
hottest := math.NaN()
for _, pattern := range []string{"/sys/class/thermal/thermal_zone*/temp", "/sys/class/hwmon/hwmon*/temp*_input"} {
files, _ := filepath.Glob(pattern)
for _, f := range files {
if v, err := readUint(f); err == nil && v > 0 && v < 150000 {
if t := float64(v) / 1000; math.IsNaN(hottest) || t > hottest {
hottest = t
}
}
}
if !math.IsNaN(hottest) {
break
}
}
return hottest
}
// 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
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"), "&#x2c", ","),
Expires: time.Unix(expire, 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 ""
}
+465
View File
@@ -0,0 +1,465 @@
package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"math"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// Prefs are the dashboard's own settings. They live beside the UI's state,
// not in settings.json: changing them needs no rebuild.
type Prefs struct {
PingTargets []string `json:"pingTargets"`
}
var defaultPrefs = Prefs{PingTargets: []string{"gateway", "1.1.1.1", "9.9.9.9"}}
func (s System) prefsPath() string { return filepath.Join(s.StateDir, "webui", "dashboard.json") }
func (s System) loadPrefs() Prefs {
data, err := os.ReadFile(s.prefsPath())
if err != nil {
return defaultPrefs
}
var p Prefs
if json.Unmarshal(data, &p) != nil || p.PingTargets == nil {
return defaultPrefs
}
return p
}
func (s System) savePrefs(p Prefs) error {
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.prefsPath()), 0o700); err != nil {
return err
}
return writeFileAtomic(s.prefsPath(), append(data, '\n'), 0o600)
}
const maxPingTargets = 5
// parsePingTargets takes targets separated by commas, spaces or lines:
// "gateway" or IP addresses.
func parsePingTargets(text string) ([]string, error) {
targets := []string{}
for _, t := range strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\r' || r == '\t' }) {
if t != "gateway" {
a, err := netip.ParseAddr(t)
if err != nil {
return nil, fmt.Errorf("%q is neither \"gateway\" nor an IP address", t)
}
t = a.String()
}
if !slices.Contains(targets, t) {
targets = append(targets, t)
}
}
if len(targets) > maxPingTargets {
return nil, fmt.Errorf("at most %d targets", maxPingTargets)
}
return targets, nil
}
// Chart is an SVG line chart, laid out here so the template only draws it.
type Chart struct {
Title string
Unit string // "bit/s", "ms", "%", "°C"
Lines []ChartLine
YTicks []ChartTick
XTicks []ChartTick
Cols []ChartCol // hover areas with a tooltip each
Empty bool
}
type ChartLine struct {
Label string
Class string // colour, see style.css
Path string // SVG path data; gaps start a new subpath
Now string // the latest value, formatted
}
type ChartTick struct {
Pos float64
Label string
}
type ChartCol struct {
X, W float64
Tip string
}
// The chart's drawing area in SVG units (the template adds the margins).
const (
chartW = 600.0
chartH = 120.0
chartCols = 200
)
type seriesSpec struct {
Name string // in the Store
Label string
Scale float64 // e.g. 8 for bytes/s -> bit/s
}
func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesSpec) Chart {
c := Chart{Title: title, Unit: unit}
var start time.Time
var step time.Duration
cols := make([][]float64, len(specs))
ymax := 0.0
for i, sp := range specs {
st, stp, vals := a.metrics.store.Read(sp.Name, tier, now)
start, step = st, stp
cols[i] = downsample(vals, chartCols, sp.Scale)
for _, v := range cols[i] {
if !math.IsNaN(v) {
ymax = math.Max(ymax, v)
}
}
}
n := chartCols
span := step * time.Duration(tiers[tier].Slots)
colDur := span / time.Duration(n)
if unit == "%" {
ymax = math.Max(ymax, 1)
if ymax > 50 {
ymax = 100
}
}
ymax = niceCeil(ymax)
c.Empty = true
for i, sp := range specs {
line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i), Now: "–"}
var path strings.Builder
pen := false
for j, v := range cols[i] {
if math.IsNaN(v) {
pen = false
continue
}
c.Empty = false
x := (float64(j) + 0.5) * chartW / float64(n)
y := chartH - v/ymax*chartH
if pen {
fmt.Fprintf(&path, "L%.1f %.1f", x, y)
} else {
fmt.Fprintf(&path, "M%.1f %.1f", x, y)
// A lone point still shows up as a short dash.
if j+1 >= len(cols[i]) || math.IsNaN(cols[i][j+1]) {
fmt.Fprintf(&path, "h1")
}
}
pen = true
line.Now = formatValue(v, unit)
}
line.Path = path.String()
c.Lines = append(c.Lines, line)
}
for k := 0; k <= 2; k++ {
v := ymax * float64(k) / 2
c.YTicks = append(c.YTicks, ChartTick{Pos: chartH - v/ymax*chartH, Label: formatValue(v, unit)})
}
layout := "15:04"
if span > 48*time.Hour {
layout = "Jan 2"
}
for k := 0; k <= 4; k++ {
t := start.Add(span * time.Duration(k) / 4)
c.XTicks = append(c.XTicks, ChartTick{Pos: chartW * float64(k) / 4, Label: t.Local().Format(layout)})
}
for j := range n {
t := start.Add(colDur * time.Duration(j))
var tip strings.Builder
tip.WriteString(t.Local().Format("Jan 2 15:04:05"))
for i, sp := range specs {
fmt.Fprintf(&tip, "\n%s: %s", sp.Label, formatValue(cols[i][j], unit))
}
c.Cols = append(c.Cols, ChartCol{X: chartW * float64(j) / float64(n), W: chartW / float64(n), Tip: tip.String()})
}
return c
}
// downsample averages vals into n columns, ignoring gaps.
func downsample(vals []float64, n int, scale float64) []float64 {
if scale == 0 {
scale = 1
}
out := make([]float64, n)
for j := range out {
lo, hi := j*len(vals)/n, (j+1)*len(vals)/n
sum, cnt := 0.0, 0
for _, v := range vals[lo:max(hi, lo+1)] {
if !math.IsNaN(v) {
sum += v
cnt++
}
}
out[j] = math.NaN()
if cnt > 0 {
out[j] = sum / float64(cnt) * scale
}
}
return out
}
// niceCeil rounds up to 1, 2 or 5 times a power of ten.
func niceCeil(v float64) float64 {
if v <= 0 || math.IsNaN(v) {
return 1
}
p := math.Pow(10, math.Floor(math.Log10(v)))
for _, m := range []float64{1, 2, 5, 10} {
if v <= m*p {
return m * p
}
}
return 10 * p
}
func formatValue(v float64, unit string) string {
if math.IsNaN(v) {
return "–"
}
switch unit {
case "bit/s":
return formatSI(v, "bit/s")
case "ms":
if v < 10 {
return fmt.Sprintf("%.1f ms", v)
}
return fmt.Sprintf("%.0f ms", v)
case "%":
if v > 0 && v < 10 {
return fmt.Sprintf("%.1f %%", v)
}
return fmt.Sprintf("%.0f %%", v)
case "°C":
return fmt.Sprintf("%.0f °C", v)
}
return fmt.Sprintf("%g %s", v, unit)
}
func formatSI(v float64, unit string) string {
for _, p := range []struct {
f float64
s string
}{{1e9, "G"}, {1e6, "M"}, {1e3, "k"}} {
if v >= p.f {
x := v / p.f
if x < 10 {
return fmt.Sprintf("%.1f %s%s", x, p.s, unit)
}
return fmt.Sprintf("%.0f %s%s", x, p.s, unit)
}
}
return fmt.Sprintf("%.0f %s", v, unit)
}
func formatBytes(v uint64) string {
f := float64(v)
for _, p := range []struct {
f float64
s string
}{{1 << 40, "TiB"}, {1 << 30, "GiB"}, {1 << 20, "MiB"}, {1 << 10, "KiB"}} {
if f >= p.f {
return fmt.Sprintf("%.1f %s", f/p.f, p.s)
}
}
return fmt.Sprintf("%d B", v)
}
// formatAge is a rough "3d 4h", "2h 5m", "4m".
func formatAge(d time.Duration) string {
if d < time.Minute {
return "<1m"
}
d = d.Round(time.Minute)
days, hours, mins := int(d.Hours())/24, int(d.Hours())%24, int(d.Minutes())%60
switch {
case days > 0:
return fmt.Sprintf("%dd %dh", days, hours)
case hours > 0:
return fmt.Sprintf("%dh %dm", hours, mins)
default:
return fmt.Sprintf("%dm", mins)
}
}
type dashboard struct {
Range string
Ranges []string
Interval int // seconds between live updates
Snap Snapshot
Leases []Lease
Events []Event
Charts []Chart
Prefs Prefs
}
func (a *app) dashboardData(rangeName string) dashboard {
tier, rangeName := rangeTier(rangeName)
now := time.Now()
s, _ := loadSettings(a.sys.settingsPath())
f := s.form()
d := dashboard{Range: rangeName, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()}
// Newest first, the last few.
ev := d.Snap.Uplink.Events
d.Events = make([]Event, 0, 10)
for i := len(ev) - 1; i >= 0 && len(d.Events) < 10; i-- {
d.Events = append(d.Events, ev[i])
}
if tier > 0 {
d.Interval = 60
}
for _, r := range ranges {
d.Ranges = append(d.Ranges, r.Name)
}
d.Leases = readLeases(a.metrics.leasesPath, f.LANs, now)
traffic := func(title, dev string) Chart {
return a.chart(title, "bit/s", tier, now, []seriesSpec{
{"if:" + dev + ":rx", "down", 8},
{"if:" + dev + ":tx", "up", 8},
})
}
up := uplinkDevice(f.WAN)
d.Charts = append(d.Charts, traffic("Internet ("+up+")", up))
for _, l := range f.LANs {
dev := l.Interface
if l.VLAN != "" {
dev += "." + l.VLAN
}
c := traffic("LAN "+l.Name+" ("+dev+")", dev)
// From the LAN's side: what the router sends is what the LAN downloads.
c.Lines[0].Label, c.Lines[1].Label = "to router", "from router"
d.Charts = append(d.Charts, c)
}
for _, wg := range wireguardDevices(s) {
d.Charts = append(d.Charts, traffic("Tunnel "+wg, wg))
}
var rtt, loss []seriesSpec
for _, t := range d.Prefs.PingTargets {
rtt = append(rtt, seriesSpec{"ping:" + t + ":rtt", t, 1})
loss = append(loss, seriesSpec{"ping:" + t + ":loss", t, 1})
}
if len(rtt) > 0 {
d.Charts = append(d.Charts, a.chart("Latency", "ms", tier, now, rtt), a.chart("Packet loss", "%", tier, now, loss))
}
d.Charts = append(d.Charts,
a.chart("CPU and memory", "%", tier, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}}))
if temp := a.chart("Temperature", "°C", tier, now, []seriesSpec{{"sys:temp", "hottest sensor", 1}}); !temp.Empty || !math.IsNaN(d.Snap.System.Temperature) {
d.Charts = append(d.Charts, temp)
}
return d
}
func wireguardDevices(s Settings) []string {
router, _ := s["router"].(map[string]any)
wgs, _ := router["wireguard"].(map[string]any)
var devs []string
for name, v := range wgs {
dev := "wg-" + name
if m, ok := v.(map[string]any); ok && str(m, "interface") != "" {
dev = str(m, "interface")
}
devs = append(devs, dev)
}
slices.Sort(devs)
return devs
}
// dashboardFragment is the live part of the overview, fetched by dashboard.js.
func (a *app) dashboardFragment(w http.ResponseWriter, r *http.Request, sess session) {
p := page{CSRF: sess.csrf, LoggedIn: true, Data: overviewData{Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
if err := a.page("overview").ExecuteTemplate(w, "dashboard", p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
targets, err := parsePingTargets(r.PostFormValue("pingTargets"))
if err == nil {
err = a.sys.savePrefs(Prefs{PingTargets: targets})
}
if err != nil {
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets not saved: "+err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape("Ping targets saved."), http.StatusSeeOther)
}
// backup streams the state directory (settings, secrets, certificate, metric
// history) as router-backup-<host>-<time>.tar.gz; router-setup can install
// from it.
func (a *app) backup(w http.ResponseWriter, r *http.Request, _ session) {
a.metrics.Save()
s, _ := loadSettings(a.sys.settingsPath())
host := str(s, "system", "hostName")
if host == "" {
host = "router"
}
name := fmt.Sprintf("router-backup-%s-%s.tar.gz", host, time.Now().Format("20060102-1504"))
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
if err := writeBackup(w, a.sys.StateDir); err != nil {
// Headers are gone; a truncated archive fails to extract.
panic(http.ErrAbortHandler)
}
}
func writeBackup(w io.Writer, dir string) error {
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !d.Type().IsRegular() {
return nil // sockets, symlinks (e.g. a result link)
}
info, err := d.Info()
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, path)
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(filepath.Join("router", rel))
if d.IsDir() {
hdr.Name += "/"
}
hdr.Uname, hdr.Gname, hdr.Uid, hdr.Gid = "", "", 0, 0
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if d.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.CopyN(tw, f, info.Size())
return err
})
return errors.Join(err, tw.Close(), gz.Close())
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestTierAveragesAndGaps(t *testing.T) {
s := newStore()
base := time.Unix(1_800_000_000, 0) // a multiple of every step
for i := range 12 { // one minute of 5 s samples
s.Add("x", base.Add(time.Duration(i)*5*time.Second), float64(i))
}
// A gap of 10 minutes, then one more sample.
s.Add("x", base.Add(11*time.Minute), 100)
now := base.Add(11 * time.Minute)
start, step, vals := s.Read("x", 0, now)
if step != 5*time.Second || len(vals) != 720 {
t.Fatalf("step %v, %d values", step, len(vals))
}
at := func(ts time.Time) float64 { return vals[int(ts.Sub(start)/step)] }
if at(base) != 0 || at(base.Add(55*time.Second)) != 11 {
t.Fatalf("5 s values: %v %v", at(base), at(base.Add(55*time.Second)))
}
if !math.IsNaN(at(base.Add(5 * time.Minute))) {
t.Fatal("the gap is not empty")
}
if at(now) != 100 || !math.IsNaN(at(base.Add(-5*time.Second))) {
t.Fatal("latest bucket or before the first sample")
}
// The minute tier averages the first minute.
start, step, vals = s.Read("x", 1, now)
if v := vals[int(base.Sub(start)/step)]; v != 5.5 {
t.Fatalf("minute average %v", v)
}
// An hour later the 5 s ring has turned over: the old values are gone.
later := now.Add(2 * time.Hour)
s.Add("x", later, 1)
_, _, vals = s.Read("x", 0, later)
for i, v := range vals[:len(vals)-1] {
if !math.IsNaN(v) {
t.Fatalf("stale value %v at %d", v, i)
}
}
}
func TestStoreSurvivesSaving(t *testing.T) {
path := filepath.Join(t.TempDir(), "m.gob")
s := newStore()
now := time.Unix(1_800_000_000, 0)
s.Add("x", now, 42)
s.SetConnected(true, now, "198.51.100.7")
if err := s.Save(path); err != nil {
t.Fatal(err)
}
l, err := loadStore(path)
if err != nil {
t.Fatal(err)
}
_, _, vals := l.Read("x", 0, now)
connected, since, events := l.Uplink()
if vals[len(vals)-1] != 42 || !connected || !since.Equal(now) || len(events) != 1 {
t.Fatalf("loaded %v %v %v %v", vals[len(vals)-1], connected, since, events)
}
}
const leaseFile = `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.10.101,52:54:00:12:34:56,01:52,3600,%d,1,0,0,laptop,0,,0
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,old&#x2cname,0,,0
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,phone,0,,0
192.168.10.102,52:54:00:dd:ee:ff,01:52,0,%d,1,0,0,gone,0,,0
192.168.10.103,52:54:00:dd:ee:00,01:52,3600,%d,1,0,0,expired,0,,0
`
func TestReadLeases(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
future, past := now.Unix()+1800, now.Unix()-10
path := filepath.Join(t.TempDir(), "dhcp4.leases")
os.WriteFile(path, []byte(fmt.Sprintf(leaseFile, future, future, future, future, past)), 0o600)
lans := []LANForm{{Name: "main", Address: "192.168.10.1"}}
got := readLeases(path, lans, now)
if len(got) != 2 || got[0].Address != "192.168.10.100" || got[0].Hostname != "phone" ||
got[1].Hostname != "laptop" || got[1].LAN != "main" {
t.Fatalf("leases: %+v", got)
}
}
func csrfFrom(t *testing.T, page string) string {
t.Helper()
m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page)
if m == nil {
t.Fatalf("no CSRF token in:\n%s", page)
}
return m[1]
}
func TestParsePingTargets(t *testing.T) {
got, err := parsePingTargets("gateway, 1.1.1.1\n2606:4700:4700::1111 1.1.1.1")
if err != nil || strings.Join(got, " ") != "gateway 1.1.1.1 2606:4700:4700::1111" {
t.Fatalf("%v %v", got, err)
}
if _, err := parsePingTargets("example.com"); err == nil {
t.Fatal("host names are not accepted")
}
if _, err := parsePingTargets("1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4 1.1.1.5 1.1.1.6"); err == nil {
t.Fatal("too many targets accepted")
}
}
func login(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login: %d", rec.Code)
}
return rec.Result().Cookies()[0]
}
func TestDashboardRenders(t *testing.T) {
a, _ := newTestApp(t)
now := time.Now()
for i := range 20 {
at := now.Add(time.Duration(i-20) * 5 * time.Second)
a.metrics.store.Add("if:eth1:rx", at, 1e6)
a.metrics.store.Add("if:eth1:tx", at, 2e5)
}
h := a.routes()
cookie := login(t, h)
for _, path := range []string{"/", "/?range=7d", "/dashboard?range=24h", "/advanced"} {
rec := do(h, "GET", path, nil, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("%s: %d\n%s", path, rec.Code, rec.Body)
}
body := rec.Body.String()
if path == "/" && (!strings.Contains(body, "Internet (eth1)") || !strings.Contains(body, "8.0 Mbit/s") ||
!strings.Contains(body, "/static/dashboard.js")) {
t.Fatalf("overview lacks the traffic chart:\n%s", body)
}
if strings.HasPrefix(path, "/dashboard") && strings.Contains(body, "<html") {
t.Fatal("the fragment is a whole page")
}
}
}
func TestBackup(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := login(t, h)
page := do(h, "GET", "/advanced", nil, cookie).Body.String()
token := csrfFrom(t, page)
rec := do(h, "POST", "/backup", url.Values{"csrf": {token}}, cookie)
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), `attachment; filename="router-backup-`) {
t.Fatalf("backup: %d %v", rec.Code, rec.Header())
}
gz, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatal(err)
}
files := map[string]string{}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(tr)
files[hdr.Name] = string(data)
}
settings, _ := os.ReadFile(sys.settingsPath())
if files["router/config/settings.json"] != string(settings) || !strings.HasPrefix(files["router/webui-password"], "pbkdf2") {
t.Fatalf("backup content: %v", keys(files))
}
if _, ok := files["router/webui/metrics.gob"]; !ok {
t.Fatalf("no metric history in the backup: %v", keys(files))
}
if rec := do(h, "POST", "/backup", url.Values{}, cookie); rec.Code != http.StatusForbidden {
t.Fatalf("backup without CSRF token: %d", rec.Code)
}
}
func keys(m map[string]string) []string {
var out []string
for k := range m {
out = append(out, k)
}
return out
}
+142 -22
View File
@@ -4,12 +4,14 @@
// activate with commit-confirm) and router-confirm. It never builds anything
// itself.
//
// router-webui [-listen :443] [-config DIR] [-state DIR]
// router-webui [-listen :443] [-config DIR] [-state DIR] [-leases FILE]
// router-webui passwd FILE (reads the new password from stdin)
// router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin)
package main
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -22,22 +24,30 @@ import (
"flag"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"math"
"math/big"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
//go:embed templates/*.html static/*
var assets embed.FS
var embedded embed.FS
// assets is what the UI serves: the embedded files, or with -assets DIR (for
// development) a directory whose templates are re-read on every request.
var assets fs.FS = embedded
func main() {
if len(os.Args) == 3 && os.Args[1] == "passwd" {
@@ -46,10 +56,30 @@ func main() {
}
return
}
if len(os.Args) == 4 && os.Args[1] == "unbundle" {
var in io.Reader = os.Stdin
if os.Args[2] != "-" {
f, err := os.Open(os.Args[2])
if err != nil {
log.Fatal(err)
}
defer f.Close()
in = f
}
if err := unbundle(in, os.Args[3]); err != nil {
log.Fatal(err)
}
return
}
listen := flag.String("listen", ":443", "address to serve HTTPS on")
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
flag.Parse()
if *dev != "" {
assets = os.DirFS(*dev)
}
sys := System{ConfigDir: *configDir, StateDir: *stateDir}
// What was installed is confirmed; later router-confirm keeps this copy.
@@ -65,14 +95,33 @@ func main() {
if err != nil {
log.Fatal(err)
}
metrics := newCollector(sys, *leases)
srv := &http.Server{
Addr: *listen,
Handler: newApp(sys).routes(),
Handler: newApp(sys, metrics, *dev != "").routes(),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second,
}
// On stop (a switch restarts the UI) the metric history is saved.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
collected := make(chan struct{})
go func() {
metrics.Run(ctx)
close(collected)
}()
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdown)
}()
log.Printf("listening on %s", *listen)
log.Fatal(srv.ListenAndServeTLS("", ""))
if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
<-collected
}
func setPassword(path string) error {
@@ -141,14 +190,18 @@ func loadOrCreateCert(dir string) (tls.Certificate, error) {
type app struct {
sys System
metrics *Collector
sessions *sessions
dev bool // re-read the templates on every request
funcs template.FuncMap
pagesMu sync.Mutex
pages map[string]*template.Template
loginMu sync.Mutex // one login attempt at a time
}
func newApp(sys System) *app {
a := &app{sys: sys, sessions: newSessions(30 * time.Minute), pages: map[string]*template.Template{}}
funcs := template.FuncMap{
func newApp(sys System, metrics *Collector, dev bool) *app {
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute)}
a.funcs = template.FuncMap{
"short": func(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
return p[i+34:] // drop /nix/store/<hash>-
@@ -158,14 +211,54 @@ func newApp(sys System) *app {
"duration": func(d time.Duration) string { return d.Round(time.Second).String() },
"seconds": func(d time.Duration) int { return int(d.Seconds()) },
"list": func(xs ...string) []string { return xs },
"bytes": formatBytes,
"age": formatAge,
"ago": func(t time.Time) string { return formatAge(time.Since(t)) },
"until": func(t time.Time) string { return formatAge(time.Until(t)) },
"value": formatValue,
"ms": func(d time.Duration) string { return formatValue(float64(d.Microseconds())/1000, "ms") },
"isNaN": math.IsNaN,
"chartW": func() float64 { return chartW },
"chartH": func() float64 { return chartH },
"join": strings.Join,
}
for _, page := range []string{"login", "overview", "network", "advanced"} {
a.pages[page] = template.Must(template.New("layout.html").Funcs(funcs).
ParseFS(assets, "templates/layout.html", "templates/"+page+".html"))
pages, err := a.parseTemplates()
if err != nil {
panic(err)
}
a.pages = pages
return a
}
func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{}
for _, page := range []string{"login", "overview", "network", "advanced", "update"} {
t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil {
return nil, err
}
pages[page] = t
}
return pages, nil
}
// page returns a template; in development it first re-reads them (a typo
// keeps the previous version and is logged).
func (a *app) page(name string) *template.Template {
a.pagesMu.Lock()
defer a.pagesMu.Unlock()
if a.dev {
if pages, err := a.parseTemplates(); err != nil {
log.Printf("templates: %v", err)
} else {
a.pages = pages
}
}
return a.pages[name]
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
static, _ := fs.Sub(assets, "static")
@@ -181,13 +274,21 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /network", a.auth(a.network))
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
mux.HandleFunc("POST /prefs", a.auth(a.savePrefs))
mux.HandleFunc("POST /backup", a.auth(a.backup))
mux.HandleFunc("GET /update", a.auth(a.updatePage))
mux.HandleFunc("POST /update/url", a.auth(a.updateSaveURL))
mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
mux.HandleFunc("POST /update/upload", a.updateUpload)
return securityHeaders(mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("X-Content-Type-Options", "nosniff")
// Not no-referrer: under it browsers send "Origin: null" with form posts.
h.Set("Referrer-Policy", "same-origin")
@@ -207,7 +308,7 @@ type page struct {
}
func (a *app) render(w http.ResponseWriter, name string, p page) {
if err := a.pages[name].Execute(w, p); err != nil {
if err := a.page(name).Execute(w, p); err != nil {
log.Printf("rendering %s: %v", name, err)
}
}
@@ -216,14 +317,17 @@ const cookieName = "router-session"
// auth requires a session; for POSTs also a matching CSRF token and, if the
// browser sends one, a same-origin Origin header.
func (a *app) session(r *http.Request) (session, bool) {
c, err := r.Cookie(cookieName)
if err != nil {
return session{}, false
}
return a.sessions.get(c.Value)
}
func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName)
var sess session
ok := err == nil
if ok {
sess, ok = a.sessions.get(c.Value)
}
sess, ok := a.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -291,9 +395,15 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request, _ session) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type overviewData struct {
Status Status
Dashboard dashboard
}
func (a *app) overview(w http.ResponseWriter, r *http.Request, sess session) {
st := a.sys.Status()
p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Data: st, Flash: r.URL.Query().Get("msg")}
p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
Data: overviewData{Status: st, Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
switch {
case st.Applying || st.ConfirmRunning:
p.Refresh = 2
@@ -417,25 +527,35 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
back(w, r, "Saved. Apply to make the changes take effect.")
}
type advancedData struct {
Settings string
PingTargets string
}
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
data, err := os.ReadFile(a.sys.settingsPath())
p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: string(data)}
p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
Data: a.advancedData(string(data))}
if err != nil {
p.Error = err.Error()
}
a.render(w, "advanced", p)
}
func (a *app) advancedData(settings string) advancedData {
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
}
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
text := r.PostFormValue("settings")
s, err := parseSettings([]byte(text))
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: text, Error: err.Error()})
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
return
}
if err := s.save(a.sys.settingsPath()); err != nil {
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: text, Error: err.Error()})
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
return
}
back(w, r, "Saved. Apply to make the changes take effect.")
+1 -1
View File
@@ -19,7 +19,7 @@ func newTestApp(t *testing.T) (*app, System) {
os.WriteFile(sys.settingsPath(), []byte(sample), 0o600)
h, _ := hashPassword("hunter2hunter2")
os.WriteFile(sys.passwordPath(), []byte(h), 0o600)
return newApp(sys), sys
return newApp(sys, newCollector(sys, filepath.Join(dir, "leases")), false), sys
}
func do(h http.Handler, method, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"encoding/gob"
"math"
"os"
"slices"
"sync"
"time"
)
// Metric history: every series keeps a ring of averages per resolution. A
// slot's index follows from its time, so writing needs no head pointer and
// gaps (the UI was not running) simply stay NaN.
//
// 5 s for 1 h, 1 min for 24 h, 15 min for 7 days, 1 h for 30 days.
var tiers = []struct {
Step int64 // seconds
Slots int
}{
{5, 720},
{60, 1440},
{900, 672},
{3600, 720},
}
// Ranges the dashboard offers, and the tier each one reads.
var ranges = []struct {
Name string
Tier int
}{
{"1h", 0},
{"24h", 1},
{"7d", 2},
{"30d", 3},
}
func rangeTier(name string) (int, string) {
for _, r := range ranges {
if r.Name == name {
return r.Tier, r.Name
}
}
return 0, ranges[0].Name
}
type Tier struct {
Step int64
Values []float64 // NaN: no data
Last int64 // start of the newest written bucket (unix seconds)
// The bucket being filled.
Bucket int64
Sum float64
Count int
}
func newTier(step int64, slots int) *Tier {
t := &Tier{Step: step, Values: make([]float64, slots)}
for i := range t.Values {
t.Values[i] = math.NaN()
}
return t
}
func (t *Tier) slot(b int64) int { return int((b / t.Step) % int64(len(t.Values))) }
func (t *Tier) add(at int64, v float64) {
b := at - at%t.Step
if b != t.Bucket {
t.flush()
t.Bucket, t.Sum, t.Count = b, 0, 0
}
t.Sum += v
t.Count++
}
// flush writes the bucket being filled into the ring.
func (t *Tier) flush() {
if t.Count == 0 || t.Bucket < t.Last {
return
}
n := int64(len(t.Values))
// Clear the slots skipped since the last write (at most one full turn).
for b, i := t.Last+t.Step, int64(0); b < t.Bucket && i < n; b, i = b+t.Step, i+1 {
t.Values[t.slot(b)] = math.NaN()
}
t.Values[t.slot(t.Bucket)] = t.Sum / float64(t.Count)
t.Last = t.Bucket
t.Count = 0
}
// read returns the buckets of the last full turn of the ring up to now, the
// one being filled included, oldest first, and the first bucket's start.
func (t *Tier) read(now int64) (int64, []float64) {
n := int64(len(t.Values))
end := now - now%t.Step
start := end - (n-1)*t.Step
out := make([]float64, n)
for i := range out {
b := start + int64(i)*t.Step
switch {
case b == t.Bucket && t.Count > 0:
out[i] = t.Sum / float64(t.Count)
case b > t.Last || b <= t.Last-n*t.Step:
out[i] = math.NaN()
default:
out[i] = t.Values[t.slot(b)]
}
}
return start, out
}
type Series struct {
Tiers []*Tier
}
func newSeries() *Series {
s := &Series{}
for _, t := range tiers {
s.Tiers = append(s.Tiers, newTier(t.Step, t.Slots))
}
return s
}
// Event is a change of the uplink's state.
type Event struct {
Time time.Time
Connected bool
Detail string
}
// Store is the history that survives restarts.
type Store struct {
mu sync.Mutex
Series map[string]*Series
// The uplink's state as last seen, and its recent changes.
Connected bool
Since time.Time
Events []Event
}
const maxEvents = 50
func newStore() *Store { return &Store{Series: map[string]*Series{}} }
func (s *Store) Add(name string, at time.Time, v float64) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
ser := s.Series[name]
if ser == nil {
ser = newSeries()
s.Series[name] = ser
}
for _, t := range ser.Tiers {
t.add(at.Unix(), v)
}
}
// Read returns a series at the resolution of tier: the start, step and values.
func (s *Store) Read(name string, tier int, now time.Time) (time.Time, time.Duration, []float64) {
s.mu.Lock()
defer s.mu.Unlock()
t := tiers[tier]
ser := s.Series[name]
if ser == nil {
ser = newSeries() // all NaN
}
start, vals := ser.Tiers[tier].read(now.Unix())
return time.Unix(start, 0), time.Duration(t.Step) * time.Second, vals
}
func (s *Store) Names() []string {
s.mu.Lock()
defer s.mu.Unlock()
names := make([]string, 0, len(s.Series))
for n := range s.Series {
names = append(names, n)
}
slices.Sort(names)
return names
}
// SetConnected records the uplink's state; a change becomes an event.
func (s *Store) SetConnected(connected bool, at time.Time, detail string) {
s.mu.Lock()
defer s.mu.Unlock()
if connected == s.Connected && !s.Since.IsZero() {
return
}
s.Connected, s.Since = connected, at
s.Events = append(s.Events, Event{Time: at, Connected: connected, Detail: detail})
if len(s.Events) > maxEvents {
s.Events = s.Events[len(s.Events)-maxEvents:]
}
}
func (s *Store) Uplink() (bool, time.Time, []Event) {
s.mu.Lock()
defer s.mu.Unlock()
return s.Connected, s.Since, slices.Clone(s.Events)
}
// The on-disk format; bump storeVersion when it changes incompatibly (an old
// file is then ignored).
const storeVersion = 1
type storeFile struct {
Version int
Series map[string]*Series
Connected bool
Since time.Time
Events []Event
}
func (s *Store) Save(path string) error {
s.mu.Lock()
f := storeFile{storeVersion, s.Series, s.Connected, s.Since, s.Events}
var buf bytes.Buffer
err := gob.NewEncoder(&buf).Encode(f)
s.mu.Unlock()
if err != nil {
return err
}
return writeFileAtomic(path, buf.Bytes(), 0o600)
}
func loadStore(path string) (*Store, error) {
data, err := os.ReadFile(path)
if err != nil {
return newStore(), err
}
var f storeFile
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&f); err != nil || f.Version != storeVersion {
return newStore(), err
}
s := &Store{Series: map[string]*Series{}, Connected: f.Connected, Since: f.Since, Events: f.Events}
for name, ser := range f.Series {
// Keep only series whose shape matches the current tiers.
ok := len(ser.Tiers) == len(tiers)
for i := 0; ok && i < len(tiers); i++ {
ok = ser.Tiers[i].Step == tiers[i].Step && len(ser.Tiers[i].Values) == tiers[i].Slots
}
if ok {
s.Series[name] = ser
}
}
return s, nil
}
+45
View File
@@ -0,0 +1,45 @@
// Live updates for the overview: refetches the dashboard part of the page
// (rendered by the server, like the page itself) and switches time ranges
// without a reload. Without JavaScript the page and its links still work.
(function () {
"use strict";
const root = document.getElementById("dashboard");
if (!root) return;
let range = root.dataset.range;
let interval = Number(root.dataset.interval) || 5;
let timer = null;
async function refresh() {
try {
const r = await fetch("/dashboard?range=" + encodeURIComponent(range), { credentials: "same-origin" });
if (r.redirected || r.status === 401 || r.status === 403) {
location.href = "/login"; // the session ended
return;
}
if (r.ok) root.innerHTML = await r.text();
} catch (e) {
// The router may be restarting the UI (an apply); try again later.
}
schedule();
}
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(refresh, interval * 1000);
}
root.addEventListener("click", function (ev) {
const a = ev.target.closest("a[data-range]");
if (!a) return;
ev.preventDefault();
range = a.dataset.range;
interval = range === "1h" ? 5 : 60;
history.replaceState(null, "", "/?range=" + encodeURIComponent(range));
refresh();
});
document.addEventListener("visibilitychange", function () {
if (document.hidden) clearTimeout(timer);
else refresh();
});
schedule();
})();
+33
View File
@@ -35,3 +35,36 @@ button.link { background: none; border: none; color: var(--accent); padding: 0;
.narrow label { margin: 0; }
pre.log { background: color-mix(in srgb, var(--line) 35%, transparent); padding: .75rem; border-radius: 6px; overflow-x: auto; font-size: 12.5px; max-height: 30rem; }
code { font-size: .9em; overflow-wrap: anywhere; }
.muted { color: var(--muted); }
.bad { color: var(--bad); }
.ok { color: var(--good); }
:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; }
@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; } }
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
.ranges a { padding: .2rem .7rem; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; }
.ranges a.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1rem; margin: 0 0 1.25rem; }
.card { border: 1px solid var(--line); border-left-width: 4px; border-radius: 6px; padding: .75rem 1rem; margin: 0; }
.card.up { border-left-color: var(--good); }
.card.degraded { border-left-color: #e8a600; }
.card.down { border-left-color: var(--bad); }
.card .state { font-weight: 600; margin: 0 0 .5rem; }
.card dl { margin: 0; font-size: .92em; }
.events { list-style: none; margin: 0; padding: 0; font-size: .9em; }
.events li span:first-child { display: inline-block; width: 2.5rem; font-weight: 600; }
.scroll { overflow-x: auto; }
.leases td, .leases th { padding: .2rem .75rem .2rem 0; white-space: nowrap; }
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); gap: 1rem; margin: 0 0 1.5rem; }
.chart { margin: 0; border: 1px solid var(--line); border-radius: 6px; padding: .5rem .75rem; min-width: 0; }
.chart figcaption { display: flex; flex-wrap: wrap; gap: .25rem 1rem; justify-content: space-between; font-size: .9em; }
.legend { display: flex; flex-wrap: wrap; gap: .1rem .9rem; }
.key::before { content: ""; display: inline-block; width: .7rem; height: .2rem; margin: 0 .3rem .2rem 0; background: currentColor; vertical-align: middle; }
.key b { font-weight: 500; color: var(--fg); }
.chart svg { width: 100%; height: auto; display: block; overflow: visible; }
.chart .grid { stroke: var(--line); stroke-width: 1; }
.chart .lab { fill: var(--muted); font-size: 11px; }
.chart .line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
.chart .hover { fill: transparent; }
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
+23 -1
View File
@@ -1,9 +1,31 @@
{{define "content"}}
<section>
<h2>Settings</h2>
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
They are checked when applied; nothing changes until then.</p>
<form method="post" action="/advanced">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<textarea name="settings" rows="30" spellcheck="false">{{.Data}}</textarea>
<textarea name="settings" rows="30" spellcheck="false">{{.Data.Settings}}</textarea>
<button>Save</button>
</form>
</section>
<section>
<h2>Dashboard</h2>
<form method="post" action="/prefs" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Ping targets <input name="pingTargets" value="{{.Data.PingTargets}}" spellcheck="false"></label>
<p class="hint">Up to 5 IP addresses, separated by commas. <code>gateway</code> is the provider's end of the line.
Takes effect right away; no apply needed.</p>
<button>Save</button>
</form>
</section>
<section>
<h2>Backup</h2>
<p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's
certificate and the dashboard history. Keep the file safe. To restore, put it on a USB stick and run
<code>router-setup</code> from the installer: it offers to install from it.</p>
<form method="post" action="/backup"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Download backup</button></form>
</section>
{{end}}
@@ -0,0 +1,82 @@
{{define "dashboard"}}
{{$d := .Data.Dashboard}}{{$u := $d.Snap.Uplink}}{{$sys := $d.Snap.System}}
<div class="ranges" role="group" aria-label="Time range">
{{range $d.Ranges}}<a href="/?range={{.}}" data-range="{{.}}"{{if eq . $d.Range}} class="active" aria-current="true"{{end}}>{{.}}</a>{{end}}
</div>
<div class="cards">
{{if $d.Snap.Time.IsZero}}
<section class="card"><h2>Internet</h2><p class="state">Collecting…</p></section>
{{else}}
<section class="card {{if and $u.Connected (or $u.Reachable (not $u.Checked))}}up{{else if $u.Connected}}degraded{{else}}down{{end}}">
<h2>Internet</h2>
<p class="state">
{{if and $u.Connected (or $u.Reachable (not $u.Checked))}}Connected
{{else if $u.Connected}}Connected, but no answer from the internet
{{else if not $u.Exists}}{{if eq $u.Type "pppoe"}}Not connected: no PPPoE session{{else}}Not connected: {{$u.Device}} is missing{{end}}
{{else}}Not connected{{end}}
{{if not $u.Since.IsZero}}<span class="muted">for {{ago $u.Since}}</span>{{end}}
</p>
<dl>
<dt>Uplink</dt><dd>{{$u.Device}} ({{if eq $u.Type "pppoe"}}PPPoE{{else}}DHCP{{end}})</dd>
{{if $u.IPv4}}<dt>IPv4</dt><dd>{{join $u.IPv4 ", "}}</dd>{{end}}
{{if $u.IPv6}}<dt>IPv6</dt><dd>{{join $u.IPv6 ", "}}</dd>{{end}}
{{if $u.Gateway}}<dt>Gateway</dt><dd>{{$u.Gateway}}</dd>{{end}}
{{range $d.Snap.Pings}}
<dt>Ping {{.Target}}</dt><dd>{{if .OK}}{{ms .RTT}}{{else if .Address}}<span class="bad">no answer</span>{{else}}<span class="muted">no address</span>{{end}}{{if and .Address (ne .Address .Target)}} <span class="muted">{{.Address}}</span>{{end}}</dd>
{{end}}
</dl>
</section>
{{end}}
<section class="card">
<h2>System</h2>
<dl>
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
<dt>Load</dt><dd>{{$sys.Load}}</dd>
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
{{if not (isNaN $sys.Temperature)}}<dt>Temperature</dt><dd>{{value $sys.Temperature "°C"}}</dd>{{end}}
{{if $sys.DiskTotal}}<dt>Disk</dt><dd>{{bytes $sys.DiskUsed}} of {{bytes $sys.DiskTotal}}</dd>{{end}}
</dl>
</section>
{{if $d.Events}}
<section class="card">
<h2>Connection log</h2>
<ul class="events">
{{range $d.Events}}<li><span class="{{if .Connected}}ok{{else}}bad{{end}}">{{if .Connected}}up{{else}}down{{end}}</span> {{.Time.Local.Format "Jan 2 15:04:05"}}{{if .Detail}} <span class="muted">{{.Detail}}</span>{{end}}</li>{{end}}
</ul>
</section>
{{end}}
</div>
<section>
<h2>DHCP clients</h2>
{{if $d.Leases}}
<div class="scroll">
<table class="leases">
<tr><th>Name</th><th>Address</th><th>MAC</th><th>LAN</th><th>Lease ends in</th></tr>
{{range $d.Leases}}<tr><td>{{or .Hostname "–"}}</td><td>{{.Address}}</td><td><code>{{.MAC}}</code></td><td>{{.LAN}}</td><td>{{until .Expires}}</td></tr>{{end}}
</table>
</div>
{{else}}<p class="muted">No active leases.</p>{{end}}
</section>
<div class="charts">
{{range $d.Charts}}
<figure class="chart">
<figcaption><strong>{{.Title}}</strong>
<span class="legend">{{range .Lines}}<span class="key {{.Class}}">{{.Label}} <b>{{.Now}}</b></span>{{end}}</span>
</figcaption>
<svg viewBox="-64 -8 690 150" role="img" aria-label="{{.Title}}">
{{range .YTicks}}<line class="grid" x1="0" x2="{{chartW}}" y1="{{.Pos}}" y2="{{.Pos}}"/><text class="lab" x="-6" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>{{end}}
{{range .XTicks}}<text class="lab" x="{{.Pos}}" y="138" text-anchor="middle">{{.Label}}</text>{{end}}
{{range .Lines}}<path class="line {{.Class}}" d="{{.Path}}"/>{{end}}
{{if .Empty}}<text class="lab" x="300" y="64" text-anchor="middle">no data yet</text>{{end}}
{{range .Cols}}<rect class="hover" x="{{.X}}" y="0" width="{{.W}}" height="{{chartH}}"><title>{{.Tip}}</title></rect>{{end}}
</svg>
</figure>
{{end}}
</div>
{{end}}
+22 -12
View File
@@ -1,12 +1,5 @@
{{define "content"}}
{{$s := .Data}}
<section>
<dl>
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
</dl>
</section>
{{$s := .Data.Status}}
{{if $s.Applying}}
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
{{else if $s.ConfirmRunning}}
@@ -18,20 +11,37 @@
the router goes back to the confirmed system on its own.</p>
<form method="post" action="/confirm"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Confirm</button></form>
</section>
{{else}}
<section>
{{if $s.Unapplied}}<p>The settings have changes that are not part of the confirmed system.</p>{{else}}<p>The settings match the confirmed system.</p>{{end}}
{{else if $s.Unapplied}}
<section class="pending">
<p>The settings have changes that are not part of the confirmed system.</p>
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
{{if $s.Unapplied}}<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>{{end}}
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>
</div>
</section>
{{end}}
<div id="dashboard" data-range="{{.Data.Dashboard.Range}}" data-interval="{{.Data.Dashboard.Interval}}">
{{template "dashboard" .}}
</div>
<section>
<h2>Configuration</h2>
{{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}<p>The settings match the confirmed system.</p>{{end}}
<dl>
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
</dl>
{{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Rebuild and apply</button></form>
{{end}}
</section>
{{if $s.Log}}
<section>
<h2>Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}</h2>
<pre class="log">{{$s.Log}}</pre>
</section>
{{end}}
<script src="/static/dashboard.js" defer></script>
{{end}}