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:
+142
-22
@@ -4,12 +4,14 @@
|
||||
// activate with commit-confirm) and router-confirm. It never builds anything
|
||||
// itself.
|
||||
//
|
||||
// router-webui [-listen :443] [-config DIR] [-state DIR]
|
||||
// router-webui [-listen :443] [-config DIR] [-state DIR] [-leases FILE]
|
||||
// router-webui passwd FILE (reads the new password from stdin)
|
||||
// router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -22,22 +24,30 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
var embedded embed.FS
|
||||
|
||||
// assets is what the UI serves: the embedded files, or with -assets DIR (for
|
||||
// development) a directory whose templates are re-read on every request.
|
||||
var assets fs.FS = embedded
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
||||
@@ -46,10 +56,30 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) == 4 && os.Args[1] == "unbundle" {
|
||||
var in io.Reader = os.Stdin
|
||||
if os.Args[2] != "-" {
|
||||
f, err := os.Open(os.Args[2])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
if err := unbundle(in, os.Args[3]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
listen := flag.String("listen", ":443", "address to serve HTTPS on")
|
||||
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
|
||||
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
|
||||
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
|
||||
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
|
||||
flag.Parse()
|
||||
if *dev != "" {
|
||||
assets = os.DirFS(*dev)
|
||||
}
|
||||
|
||||
sys := System{ConfigDir: *configDir, StateDir: *stateDir}
|
||||
// What was installed is confirmed; later router-confirm keeps this copy.
|
||||
@@ -65,14 +95,33 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
metrics := newCollector(sys, *leases)
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: newApp(sys).routes(),
|
||||
Handler: newApp(sys, metrics, *dev != "").routes(),
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// On stop (a switch restarts the UI) the metric history is saved.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
collected := make(chan struct{})
|
||||
go func() {
|
||||
metrics.Run(ctx)
|
||||
close(collected)
|
||||
}()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(shutdown)
|
||||
}()
|
||||
log.Printf("listening on %s", *listen)
|
||||
log.Fatal(srv.ListenAndServeTLS("", ""))
|
||||
if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-collected
|
||||
}
|
||||
|
||||
func setPassword(path string) error {
|
||||
@@ -141,14 +190,18 @@ func loadOrCreateCert(dir string) (tls.Certificate, error) {
|
||||
|
||||
type app struct {
|
||||
sys System
|
||||
metrics *Collector
|
||||
sessions *sessions
|
||||
dev bool // re-read the templates on every request
|
||||
funcs template.FuncMap
|
||||
pagesMu sync.Mutex
|
||||
pages map[string]*template.Template
|
||||
loginMu sync.Mutex // one login attempt at a time
|
||||
}
|
||||
|
||||
func newApp(sys System) *app {
|
||||
a := &app{sys: sys, sessions: newSessions(30 * time.Minute), pages: map[string]*template.Template{}}
|
||||
funcs := template.FuncMap{
|
||||
func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute)}
|
||||
a.funcs = template.FuncMap{
|
||||
"short": func(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
|
||||
return p[i+34:] // drop /nix/store/<hash>-
|
||||
@@ -158,14 +211,54 @@ func newApp(sys System) *app {
|
||||
"duration": func(d time.Duration) string { return d.Round(time.Second).String() },
|
||||
"seconds": func(d time.Duration) int { return int(d.Seconds()) },
|
||||
"list": func(xs ...string) []string { return xs },
|
||||
"bytes": formatBytes,
|
||||
"age": formatAge,
|
||||
"ago": func(t time.Time) string { return formatAge(time.Since(t)) },
|
||||
"until": func(t time.Time) string { return formatAge(time.Until(t)) },
|
||||
"value": formatValue,
|
||||
"ms": func(d time.Duration) string { return formatValue(float64(d.Microseconds())/1000, "ms") },
|
||||
"isNaN": math.IsNaN,
|
||||
"chartW": func() float64 { return chartW },
|
||||
"chartH": func() float64 { return chartH },
|
||||
"join": strings.Join,
|
||||
}
|
||||
for _, page := range []string{"login", "overview", "network", "advanced"} {
|
||||
a.pages[page] = template.Must(template.New("layout.html").Funcs(funcs).
|
||||
ParseFS(assets, "templates/layout.html", "templates/"+page+".html"))
|
||||
pages, err := a.parseTemplates()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
a.pages = pages
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"login", "overview", "network", "advanced", "update"} {
|
||||
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages[page] = t
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// page returns a template; in development it first re-reads them (a typo
|
||||
// keeps the previous version and is logged).
|
||||
func (a *app) page(name string) *template.Template {
|
||||
a.pagesMu.Lock()
|
||||
defer a.pagesMu.Unlock()
|
||||
if a.dev {
|
||||
if pages, err := a.parseTemplates(); err != nil {
|
||||
log.Printf("templates: %v", err)
|
||||
} else {
|
||||
a.pages = pages
|
||||
}
|
||||
}
|
||||
return a.pages[name]
|
||||
}
|
||||
|
||||
func (a *app) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
static, _ := fs.Sub(assets, "static")
|
||||
@@ -181,13 +274,21 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("POST /network", a.auth(a.network))
|
||||
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
||||
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
||||
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
|
||||
mux.HandleFunc("POST /prefs", a.auth(a.savePrefs))
|
||||
mux.HandleFunc("POST /backup", a.auth(a.backup))
|
||||
mux.HandleFunc("GET /update", a.auth(a.updatePage))
|
||||
mux.HandleFunc("POST /update/url", a.auth(a.updateSaveURL))
|
||||
mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
|
||||
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
|
||||
mux.HandleFunc("POST /update/upload", a.updateUpload)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
// Not no-referrer: under it browsers send "Origin: null" with form posts.
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
@@ -207,7 +308,7 @@ type page struct {
|
||||
}
|
||||
|
||||
func (a *app) render(w http.ResponseWriter, name string, p page) {
|
||||
if err := a.pages[name].Execute(w, p); err != nil {
|
||||
if err := a.page(name).Execute(w, p); err != nil {
|
||||
log.Printf("rendering %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -216,14 +317,17 @@ const cookieName = "router-session"
|
||||
|
||||
// auth requires a session; for POSTs also a matching CSRF token and, if the
|
||||
// browser sends one, a same-origin Origin header.
|
||||
func (a *app) session(r *http.Request) (session, bool) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return session{}, false
|
||||
}
|
||||
return a.sessions.get(c.Value)
|
||||
}
|
||||
|
||||
func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
var sess session
|
||||
ok := err == nil
|
||||
if ok {
|
||||
sess, ok = a.sessions.get(c.Value)
|
||||
}
|
||||
sess, ok := a.session(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
@@ -291,9 +395,15 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type overviewData struct {
|
||||
Status Status
|
||||
Dashboard dashboard
|
||||
}
|
||||
|
||||
func (a *app) overview(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
st := a.sys.Status()
|
||||
p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Data: st, Flash: r.URL.Query().Get("msg")}
|
||||
p := page{Title: "Overview", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
|
||||
Data: overviewData{Status: st, Dashboard: a.dashboardData(r.URL.Query().Get("range"))}}
|
||||
switch {
|
||||
case st.Applying || st.ConfirmRunning:
|
||||
p.Refresh = 2
|
||||
@@ -417,25 +527,35 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
|
||||
type advancedData struct {
|
||||
Settings string
|
||||
PingTargets string
|
||||
}
|
||||
|
||||
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
data, err := os.ReadFile(a.sys.settingsPath())
|
||||
p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: string(data)}
|
||||
p := page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Flash: r.URL.Query().Get("msg"),
|
||||
Data: a.advancedData(string(data))}
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
}
|
||||
a.render(w, "advanced", p)
|
||||
}
|
||||
|
||||
func (a *app) advancedData(settings string) advancedData {
|
||||
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
|
||||
}
|
||||
|
||||
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
text := r.PostFormValue("settings")
|
||||
s, err := parseSettings([]byte(text))
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: text, Error: err.Error()})
|
||||
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: text, Error: err.Error()})
|
||||
a.render(w, "advanced", page{Title: "Advanced", CSRF: sess.csrf, LoggedIn: true, Data: a.advancedData(text), Error: err.Error()})
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
|
||||
Reference in New Issue
Block a user