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:
@@ -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"), ",", ","),
|
||||
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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,name,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
@@ -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.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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); }
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user