Files
nixos-router/pkgs/router-webui/main.go
T
nilsandClaude Sonnet 5.5 f839e7fffb Add DHCP reservations to the web UI, split out a fast webui test
The DHCP page edits the reservations per LAN (checked like the module's
assertions) and offers active clients for reservation. The web UI checks
that need no installed system moved from apply.nix into webui.nix (one
node, under a minute); apply.nix shares one apply for the pool change and
a reservation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 21:41:43 +01:00

567 lines
17 KiB
Go

// router-webui: a small web UI for a nixos-router configured by settings.json.
//
// It edits the settings, then asks systemd to run router-apply (build and
// activate with commit-confirm) and router-confirm. It never builds anything
// itself.
//
// 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"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"embed"
"encoding/pem"
"errors"
"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 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" {
if err := setPassword(os.Args[2]); err != nil {
log.Fatal(err)
}
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.
if _, err := os.Stat(sys.confirmedPath()); errors.Is(err, fs.ErrNotExist) {
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
if err := writeFileAtomic(sys.confirmedPath(), data, 0o600); err != nil {
log.Printf("recording confirmed settings: %v", err)
}
}
}
cert, err := loadOrCreateCert(filepath.Join(*stateDir, "webui"))
if err != nil {
log.Fatal(err)
}
metrics := newCollector(sys, *leases)
srv := &http.Server{
Addr: *listen,
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)
if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
<-collected
}
func setPassword(path string) error {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return fmt.Errorf("reading the password from stdin: %w", err)
}
password := strings.TrimRight(line, "\r\n")
if len(password) < 8 {
return errors.New("the password must have at least 8 characters")
}
hash, err := hashPassword(password)
if err != nil {
return err
}
return writeFileAtomic(path, []byte(hash+"\n"), 0o600)
}
// loadOrCreateCert keeps a self-signed certificate in dir; browsers will warn
// once, then the fingerprint stays the same.
func loadOrCreateCert(dir string) (tls.Certificate, error) {
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err == nil {
return cert, nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return tls.Certificate{}, err
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
host, _ := os.Hostname()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: host},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(20, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{host, "localhost"},
}
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP)
}
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, err
}
if err := writeFileAtomic(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
return tls.Certificate{}, err
}
if err := writeFileAtomic(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return tls.Certificate{}, err
}
return tls.LoadX509KeyPair(certFile, keyFile)
}
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, 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>-
}
return p
},
"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,
}
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", "dhcp", "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")
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("POST /logout", a.auth(a.logout))
mux.HandleFunc("GET /{$}", a.auth(a.overview))
mux.HandleFunc("POST /apply", a.auth(a.apply))
mux.HandleFunc("POST /confirm", a.auth(a.confirm))
mux.HandleFunc("POST /discard", a.auth(a.discard))
mux.HandleFunc("GET /network", a.auth(a.networkPage))
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 /dhcp", a.auth(a.dhcpPage))
mux.HandleFunc("POST /dhcp", a.auth(a.dhcp))
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'; 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")
h.Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
type page struct {
Title string
CSRF string
Refresh int // seconds; 0 = none
Flash string
Error string
LoggedIn bool
Data any
}
func (a *app) render(w http.ResponseWriter, name string, p page) {
if err := a.page(name).Execute(w, p); err != nil {
log.Printf("rendering %s: %v", name, err)
}
}
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) {
sess, ok := a.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
if !sameOrigin(r) || r.PostFormValue("csrf") != sess.csrf {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
h(w, r, sess)
}
}
// Sec-Fetch-Site first (browsers may send "Origin: null" for same-origin
// form posts), then Origin; requests with neither (curl) pass.
var crossOrigin = http.NewCrossOriginProtection()
func sameOrigin(r *http.Request) bool {
return crossOrigin.Check(r) == nil
}
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
p := page{Title: "Log in"}
if _, err := os.Stat(a.sys.passwordPath()); err != nil {
p.Error = "No password is set. Set one on the router: router-webui passwd " + a.sys.passwordPath()
}
a.render(w, "login", p)
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
a.loginMu.Lock()
defer a.loginMu.Unlock()
stored, err := os.ReadFile(a.sys.passwordPath())
ok := false
if err == nil {
ok, err = checkPassword(string(stored), r.PostFormValue("password"))
if err != nil {
log.Printf("password file: %v", err)
}
}
if !ok {
time.Sleep(time.Second)
w.WriteHeader(http.StatusUnauthorized)
a.render(w, "login", page{Title: "Log in", Error: "Wrong password."})
return
}
id, _ := a.sessions.create()
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: id, Path: "/",
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *app) logout(w http.ResponseWriter, r *http.Request, _ session) {
if c, err := r.Cookie(cookieName); err == nil {
a.sessions.delete(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Path: "/", MaxAge: -1, Secure: true, HttpOnly: true})
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, 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
case st.RollbackIn > 0:
p.Refresh = 10
}
a.render(w, "overview", p)
}
func back(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
func (a *app) apply(w http.ResponseWriter, r *http.Request, _ session) {
if err := a.sys.Apply(); err != nil {
back(w, r, "Could not start applying: "+err.Error())
return
}
back(w, r, "Applying the settings.")
}
func (a *app) confirm(w http.ResponseWriter, r *http.Request, _ session) {
if err := a.sys.Confirm(); err != nil {
back(w, r, "Could not confirm: "+err.Error())
return
}
back(w, r, "Confirming.")
}
func (a *app) discard(w http.ResponseWriter, r *http.Request, _ session) {
if err := a.sys.Discard(); err != nil {
back(w, r, "Could not discard: "+err.Error())
return
}
back(w, r, "Changes discarded; the settings match the confirmed system again.")
}
type networkData struct {
Form Form
Errors FormErrors
Interfaces []string
Devices []Device
}
func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session) {
s, err := loadSettings(a.sys.settingsPath())
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true}
if err != nil {
p.Error = err.Error()
a.render(w, "network", p)
return
}
f := s.form()
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: f.devices(Interfaces())}
a.render(w, "network", p)
}
func formFromRequest(r *http.Request) Form {
f := Form{
HostName: r.PostFormValue("hostName"),
WAN: WANForm{
Interface: strings.TrimSpace(r.PostFormValue("wan.interface")),
Type: r.PostFormValue("wan.type"),
PPPoEUser: r.PostFormValue("wan.pppoe.username"),
IPv6: r.PostFormValue("wan.ipv6") == "on",
PPPoEPassword: r.PostFormValue("wan.pppoe.password"),
},
}
n, _ := strconv.Atoi(r.PostFormValue("lans"))
for i := range min(n, 64) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("lan.%d.%s", i, k)) }
f.LANs = append(f.LANs, LANForm{
Name: v("name"),
Interface: strings.TrimSpace(v("interface")),
VLAN: v("vlan"),
Address: v("address"),
Prefix: v("prefix"),
Internet: v("internet") == "on",
IPv6PD: v("ipv6pd") == "on",
DHCP: v("dhcp") == "on",
PoolStart: v("poolStart"),
PoolEnd: v("poolEnd"),
Remove: v("remove") == "on",
})
}
return f
}
func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "Network", CSRF: sess.csrf, LoggedIn: true}
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
p.Error = err.Error()
a.render(w, "network", p)
return
}
f := formFromRequest(r)
password, err := s.apply(f, a.sys.pppoePath())
if err != nil {
var fe FormErrors
errors.As(err, &fe)
f.WAN.PPPoEPassword = ""
f.WAN.PasswordSet = s.form().WAN.PasswordSet
p.Error = "Please correct the marked fields."
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: f.devices(Interfaces())}
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "network", p)
return
}
if password != "" {
if err := writeFileAtomic(a.sys.pppoePath(), []byte(password), 0o600); err != nil {
p.Error = "Storing the PPPoE password: " + err.Error()
a.render(w, "network", p)
return
}
}
if err := s.save(a.sys.settingsPath()); err != nil {
p.Error = "Saving: " + err.Error()
a.render(w, "network", p)
return
}
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, 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: 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: a.advancedData(text), Error: err.Error()})
return
}
back(w, r, "Saved. Apply to make the changes take effect.")
}