The firewall page now manages zones (assign each LAN or tunnel, add and remove zones) and rules with zone, address and port on both sides plus the protocol. router.firewall.zones declares zones without members and rules take sourcePorts. The temperature chart draws every thermal zone and hwmon sensor; the overview defaults to 15 minutes, 1h stays an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
476 lines
12 KiB
Go
476 lines
12 KiB
Go
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"`
|
||
// VLANs defined in the network page ("enp2s0.1001") that may not be used
|
||
// yet; the interface dropdowns offer them.
|
||
VLANs []string `json:"vlans,omitempty"`
|
||
}
|
||
|
||
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, rg timeRange, 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, rg.Tier, now)
|
||
if keep := rg.slots(); keep < len(vals) {
|
||
st = st.Add(stp * time.Duration(len(vals)-keep))
|
||
vals = vals[len(vals)-keep:]
|
||
}
|
||
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(rg.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%10), 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 {
|
||
rg := findRange(rangeName)
|
||
now := time.Now()
|
||
s, _ := loadSettings(a.sys.settingsPath())
|
||
f := s.form()
|
||
d := dashboard{Range: rg.Name, 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 rg.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", rg, 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
|
||
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", rg, now, rtt), a.chart("Packet loss", "%", rg, now, loss))
|
||
}
|
||
d.Charts = append(d.Charts,
|
||
a.chart("CPU and memory", "%", rg, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}}))
|
||
var temps []seriesSpec
|
||
for _, t := range d.Snap.System.Sensors {
|
||
temps = append(temps, seriesSpec{"temp:" + t.Key, t.Label, 1})
|
||
}
|
||
if len(temps) > 0 {
|
||
d.Charts = append(d.Charts, a.chart("Temperature", "°C", rg, now, temps))
|
||
}
|
||
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 {
|
||
prefs := a.sys.loadPrefs()
|
||
prefs.PingTargets = targets
|
||
err = a.sys.savePrefs(prefs)
|
||
}
|
||
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())
|
||
}
|