Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
495 lines
13 KiB
Go
495 lines
13 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"`
|
||
// Theme is "light" or "dark"; empty follows the browser's setting.
|
||
Theme string `json:"theme,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)
|
||
}
|
||
}
|
||
|
||
// savePrefs takes the Advanced page's two small forms: the ping targets, or
|
||
// the theme.
|
||
func (a *app) savePrefs(w http.ResponseWriter, r *http.Request, sess session) {
|
||
_ = r.ParseForm()
|
||
prefs := a.sys.loadPrefs()
|
||
what := "Ping targets"
|
||
var err error
|
||
if r.PostForm.Has("theme") {
|
||
what = "Appearance"
|
||
switch t := r.PostFormValue("theme"); t {
|
||
case "", "light", "dark":
|
||
prefs.Theme = t
|
||
default:
|
||
err = fmt.Errorf("unknown theme %q", t)
|
||
}
|
||
} else {
|
||
var targets []string
|
||
if targets, err = parsePingTargets(r.PostFormValue("pingTargets")); err == nil {
|
||
prefs.PingTargets = targets
|
||
}
|
||
}
|
||
if err == nil {
|
||
err = a.sys.savePrefs(prefs)
|
||
}
|
||
if err != nil {
|
||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" not saved: "+err.Error()), http.StatusSeeOther)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/advanced?msg="+url.QueryEscape(what+" 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())
|
||
}
|