Files
nilsandClaude Sonnet 5.5 586274af4e Add the appliance web UI look, interface names, MCP and REST API, local zone
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
2026-11-01 16:56:59 +01:00

495 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}