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:
@@ -66,6 +66,8 @@ let
|
|||||||
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
imports = [ ./update.nix ];
|
||||||
|
|
||||||
# Outside `router`: the settings file defines that whole tree.
|
# Outside `router`: the settings file defines that whole tree.
|
||||||
options.routerSettings = {
|
options.routerSettings = {
|
||||||
file = lib.mkOption {
|
file = lib.mkOption {
|
||||||
@@ -154,7 +156,11 @@ in
|
|||||||
description = "Router web UI";
|
description = "Router web UI";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
path = [ config.systemd.package ];
|
# iproute2: the dashboard reads the default route and the PPP peer.
|
||||||
|
path = [
|
||||||
|
config.systemd.package
|
||||||
|
pkgs.iproute2
|
||||||
|
];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}";
|
ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
|
|||||||
@@ -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
|
// activate with commit-confirm) and router-confirm. It never builds anything
|
||||||
// itself.
|
// 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 passwd FILE (reads the new password from stdin)
|
||||||
|
// router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -22,22 +24,30 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html static/*
|
//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() {
|
func main() {
|
||||||
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
||||||
@@ -46,10 +56,30 @@ func main() {
|
|||||||
}
|
}
|
||||||
return
|
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")
|
listen := flag.String("listen", ":443", "address to serve HTTPS on")
|
||||||
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
|
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
|
||||||
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
|
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()
|
flag.Parse()
|
||||||
|
if *dev != "" {
|
||||||
|
assets = os.DirFS(*dev)
|
||||||
|
}
|
||||||
|
|
||||||
sys := System{ConfigDir: *configDir, StateDir: *stateDir}
|
sys := System{ConfigDir: *configDir, StateDir: *stateDir}
|
||||||
// What was installed is confirmed; later router-confirm keeps this copy.
|
// What was installed is confirmed; later router-confirm keeps this copy.
|
||||||
@@ -65,14 +95,33 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
metrics := newCollector(sys, *leases)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: *listen,
|
Addr: *listen,
|
||||||
Handler: newApp(sys).routes(),
|
Handler: newApp(sys, metrics, *dev != "").routes(),
|
||||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
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.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 {
|
func setPassword(path string) error {
|
||||||
@@ -141,14 +190,18 @@ func loadOrCreateCert(dir string) (tls.Certificate, error) {
|
|||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
sys System
|
sys System
|
||||||
|
metrics *Collector
|
||||||
sessions *sessions
|
sessions *sessions
|
||||||
|
dev bool // re-read the templates on every request
|
||||||
|
funcs template.FuncMap
|
||||||
|
pagesMu sync.Mutex
|
||||||
pages map[string]*template.Template
|
pages map[string]*template.Template
|
||||||
loginMu sync.Mutex // one login attempt at a time
|
loginMu sync.Mutex // one login attempt at a time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApp(sys System) *app {
|
func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||||
a := &app{sys: sys, sessions: newSessions(30 * time.Minute), pages: map[string]*template.Template{}}
|
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute)}
|
||||||
funcs := template.FuncMap{
|
a.funcs = template.FuncMap{
|
||||||
"short": func(p string) string {
|
"short": func(p string) string {
|
||||||
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
|
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
|
||||||
return p[i+34:] // drop /nix/store/<hash>-
|
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() },
|
"duration": func(d time.Duration) string { return d.Round(time.Second).String() },
|
||||||
"seconds": func(d time.Duration) int { return int(d.Seconds()) },
|
"seconds": func(d time.Duration) int { return int(d.Seconds()) },
|
||||||
"list": func(xs ...string) []string { return xs },
|
"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"} {
|
pages, err := a.parseTemplates()
|
||||||
a.pages[page] = template.Must(template.New("layout.html").Funcs(funcs).
|
if err != nil {
|
||||||
ParseFS(assets, "templates/layout.html", "templates/"+page+".html"))
|
panic(err)
|
||||||
}
|
}
|
||||||
|
a.pages = pages
|
||||||
return a
|
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 {
|
func (a *app) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
static, _ := fs.Sub(assets, "static")
|
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("POST /network", a.auth(a.network))
|
||||||
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
||||||
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
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)
|
return securityHeaders(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
func securityHeaders(next http.Handler) http.Handler {
|
func securityHeaders(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
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")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
// Not no-referrer: under it browsers send "Origin: null" with form posts.
|
// Not no-referrer: under it browsers send "Origin: null" with form posts.
|
||||||
h.Set("Referrer-Policy", "same-origin")
|
h.Set("Referrer-Policy", "same-origin")
|
||||||
@@ -207,7 +308,7 @@ type page struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) render(w http.ResponseWriter, name string, p page) {
|
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)
|
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
|
// auth requires a session; for POSTs also a matching CSRF token and, if the
|
||||||
// browser sends one, a same-origin Origin header.
|
// 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 {
|
func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
c, err := r.Cookie(cookieName)
|
sess, ok := a.session(r)
|
||||||
var sess session
|
|
||||||
ok := err == nil
|
|
||||||
if ok {
|
|
||||||
sess, ok = a.sessions.get(c.Value)
|
|
||||||
}
|
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -291,9 +395,15 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request, _ session) {
|
|||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
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) {
|
func (a *app) overview(w http.ResponseWriter, r *http.Request, sess session) {
|
||||||
st := a.sys.Status()
|
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 {
|
switch {
|
||||||
case st.Applying || st.ConfirmRunning:
|
case st.Applying || st.ConfirmRunning:
|
||||||
p.Refresh = 2
|
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.")
|
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) {
|
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||||
data, err := os.ReadFile(a.sys.settingsPath())
|
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 {
|
if err != nil {
|
||||||
p.Error = err.Error()
|
p.Error = err.Error()
|
||||||
}
|
}
|
||||||
a.render(w, "advanced", p)
|
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) {
|
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
|
||||||
text := r.PostFormValue("settings")
|
text := r.PostFormValue("settings")
|
||||||
s, err := parseSettings([]byte(text))
|
s, err := parseSettings([]byte(text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
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)
|
os.WriteFile(sys.settingsPath(), []byte(sample), 0o600)
|
||||||
h, _ := hashPassword("hunter2hunter2")
|
h, _ := hashPassword("hunter2hunter2")
|
||||||
os.WriteFile(sys.passwordPath(), []byte(h), 0o600)
|
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 {
|
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; }
|
.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; }
|
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; }
|
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"}}
|
{{define "content"}}
|
||||||
|
<section>
|
||||||
|
<h2>Settings</h2>
|
||||||
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
|
<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>
|
They are checked when applied; nothing changes until then.</p>
|
||||||
<form method="post" action="/advanced">
|
<form method="post" action="/advanced">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<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>
|
<button>Save</button>
|
||||||
</form>
|
</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}}
|
{{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"}}
|
{{define "content"}}
|
||||||
{{$s := .Data}}
|
{{$s := .Data.Status}}
|
||||||
<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>
|
|
||||||
|
|
||||||
{{if $s.Applying}}
|
{{if $s.Applying}}
|
||||||
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
|
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
|
||||||
{{else if $s.ConfirmRunning}}
|
{{else if $s.ConfirmRunning}}
|
||||||
@@ -18,20 +11,37 @@
|
|||||||
the router goes back to the confirmed system on its own.</p>
|
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>
|
<form method="post" action="/confirm"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Confirm</button></form>
|
||||||
</section>
|
</section>
|
||||||
{{else}}
|
{{else if $s.Unapplied}}
|
||||||
<section>
|
<section class="pending">
|
||||||
{{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}}
|
<p>The settings have changes that are not part of the confirmed system.</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{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}}
|
{{if $s.Log}}
|
||||||
<section>
|
<section>
|
||||||
<h2>Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}</h2>
|
<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>
|
<pre class="log">{{$s.Log}}</pre>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<script src="/static/dashboard.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -196,5 +196,25 @@ pkgs.testers.runNixOSTest {
|
|||||||
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
||||||
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
||||||
assert "match the confirmed system" in curl(f"{ui}/")
|
assert "match the confirmed system" in curl(f"{ui}/")
|
||||||
|
|
||||||
|
with subtest("the dashboard shows the uplink, the LAN and pings from the router"):
|
||||||
|
curl(f"-d csrf={csrf('/advanced')} --data-urlencode 'pingTargets=gateway, 192.168.10.50' {ui}/prefs")
|
||||||
|
# The UI pings from its sandbox (raw socket); the client answers.
|
||||||
|
# (Into a file: grep -q would end curl with SIGPIPE, failing the pipe.)
|
||||||
|
client.wait_until_succeeds(
|
||||||
|
f"curl -sk -b /tmp/jar -o /tmp/dash '{ui}/dashboard?range=1h' && grep -q 'Ping 192.168.10.50</dt><dd>[0-9.]* ms' /tmp/dash",
|
||||||
|
timeout=60)
|
||||||
|
page = curl(f"{ui}/")
|
||||||
|
# No DHCP server on the uplink in this test.
|
||||||
|
for text in ["Not connected", "LAN main (eth2)", "Internet (eth1)", "Latency", "CPU and memory", "/static/dashboard.js"]:
|
||||||
|
assert text in page, f"{text!r} missing:\n{page}"
|
||||||
|
assert "<html" not in curl(f"'{ui}/dashboard?range=7d'")
|
||||||
|
client.succeed("curl -sfk -o /dev/null https://192.168.10.1/static/dashboard.js")
|
||||||
|
|
||||||
|
with subtest("the web UI hands out a backup of the router's state"):
|
||||||
|
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
|
||||||
|
files = client.succeed("tar -tzf /tmp/backup.tar.gz")
|
||||||
|
for f in ["router/config/settings.json", "router/webui-password", "router/webui/cert.pem", "router/webui/dashboard.json"]:
|
||||||
|
assert f in files.split(), files
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user