Files
nixos-router/pkgs/router-webui/main.go
T
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

831 lines
26 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)
// router-webui leases-dns (publishes DHCP clients' names in Unbound)
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"
"slices"
"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) > 1 && os.Args[1] == "leases-dns" {
leasesDNS(os.Args[2:])
return
}
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")
httpListen := flag.String("http", "", "address that redirects HTTP to HTTPS (empty: none)")
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
options := flag.String("options", "", "JSON documentation of the router options (offered to MCP clients)")
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, ACMEDir: *acmeDir}
// 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: withOptions(newApp(sys, metrics, *dev != ""), *options).routes(),
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, 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)
}()
if *httpListen != "" {
plain := &http.Server{Addr: *httpListen, Handler: redirectToHTTPS(*listen), ReadHeaderTimeout: 10 * time.Second}
go func() {
if err := plain.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Printf("http redirect: %v", err)
}
}()
go func() {
<-ctx.Done()
plain.Close()
}()
}
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
tokens *tokenStore
settingsMu sync.Mutex // edits through the API read, patch and write the settings file
optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none)
}
func withOptions(a *app, file string) *app {
a.optionsFile = file
return a
}
func newApp(sys System, metrics *Collector, dev bool) *app {
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute), tokens: newTokenStore(sys.tokensPath())}
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,
"choices": choices,
"tform": func(t WGTunnel, csrf string, isNew bool) map[string]any {
return map[string]any{"T": t, "CSRF": csrf, "New": isNew}
},
}
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", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes"} {
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("POST /reboot", a.auth(a.reboot))
mux.HandleFunc("POST /password", a.auth(a.password))
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("POST /vlan", a.auth(a.vlan))
mux.HandleFunc("POST /interfaces", a.auth(a.interfaces))
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
mux.HandleFunc("POST /dns", a.auth(a.dns))
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
mux.HandleFunc("POST /wireguard/save", a.auth(a.wireguardSave))
mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete))
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
mux.HandleFunc("GET /ddns", a.auth(a.ddnsPage))
mux.HandleFunc("POST /ddns", a.auth(a.ddnsSave))
mux.HandleFunc("GET /dnsproviders", a.auth(a.providersPage))
mux.HandleFunc("POST /dnsproviders", a.auth(a.providersSave))
mux.HandleFunc("GET /certificate", a.auth(a.certificatePage))
mux.HandleFunc("POST /certificate", a.auth(a.certificateSave))
mux.HandleFunc("GET /firewall", a.auth(a.firewallPage))
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
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)
mux.HandleFunc("/mcp", a.mcp)
a.registerAPI(mux)
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
mux.HandleFunc("POST /tokens", a.auth(a.tokenCreate))
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
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
Theme string // set by render: "", "light" or "dark"
}
func (a *app) render(w http.ResponseWriter, name string, p page) {
p.Theme = a.sys.loadPrefs().Theme
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()
// redirectToHTTPS sends plain HTTP requests to the same host and path over
// HTTPS; httpsAddr is where the HTTPS server listens.
func redirectToHTTPS(httpsAddr string) http.Handler {
_, port, _ := net.SplitHostPort(httpsAddr)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if strings.Contains(host, ":") { // an IPv6 literal
host = "[" + strings.Trim(host, "[]") + "]"
}
if port != "" && port != "443" {
host += ":" + port
}
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
})
}
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.")
}
func (a *app) reboot(w http.ResponseWriter, r *http.Request, _ session) {
if err := a.sys.Reboot(); err != nil {
back(w, r, "Could not reboot: "+err.Error())
return
}
back(w, r, "Rebooting in a few seconds. This page works again when the router is back.")
}
// password changes the web UI's password and root's, which are one.
func (a *app) password(w http.ResponseWriter, r *http.Request, _ session) {
a.loginMu.Lock()
defer a.loginMu.Unlock()
stored, err := os.ReadFile(a.sys.passwordPath())
ok := false
if err == nil {
ok, _ = checkPassword(string(stored), r.PostFormValue("current"))
}
next := r.PostFormValue("new")
switch {
case !ok:
time.Sleep(time.Second)
back(w, r, "The current password is wrong.")
case len(next) < 8:
back(w, r, "The new password must have at least 8 characters.")
case next != r.PostFormValue("confirm"):
back(w, r, "The new password and its confirmation differ.")
default:
if err := setRootPassword(a.sys, next); err != nil {
back(w, r, "Could not change the password: "+err.Error())
return
}
hash, err := hashPassword(next)
if err == nil {
err = writeFileAtomic(a.sys.passwordPath(), []byte(hash+"\n"), 0o600)
}
if err != nil {
back(w, r, "Root's password changed, the web UI's did not: "+err.Error())
return
}
back(w, r, "Password changed for the web UI and for root.")
}
}
type networkData struct {
Form Form
Errors FormErrors
Interfaces []string
Devices []Device
}
// devices lists what the interface dropdowns offer for the form f.
func (a *app) devices(s Settings, f Form) []Device {
names := s.cardNames()
var physical []string
nics := map[string]NIC{}
for _, n := range NICs() {
eff := effectiveName(names, n)
physical = append(physical, eff)
nics[eff] = n
}
devs := f.devices(physical, a.sys.loadPrefs().VLANs)
custom := s.vlanNames()
for i, d := range devs {
if d.Kind == "VLAN" {
devs[i].Custom = custom[d.Name]
} else if n, ok := nics[d.Name]; ok {
devs[i].Kernel, devs[i].MAC = n.Kernel, n.MAC
}
}
return devs
}
// kernelName is the kernel's name for the card with this MAC address.
func kernelName(mac string) string {
for _, n := range NICs() {
if n.MAC == mac {
return n.Kernel
}
}
return ""
}
// interfaces saves the names typed into the interface table. A new name is
// used by the router after the next apply and reboot.
func (a *app) interfaces(w http.ResponseWriter, r *http.Request, _ session) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
n, _ := strconv.Atoi(r.PostFormValue("rows"))
var rows []Rename
for i := range min(n, 256) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d", k, i)) }
rows = append(rows, Rename{Dev: v("dev"), MAC: v("mac"), From: v("from"), To: v("name")})
}
moved, err := s.rename(rows, kernelName)
msg := ""
if err == nil {
err = s.save(a.sys.settingsPath())
}
if err == nil {
// The UI's defined VLANs follow a renamed card.
prefs := a.sys.loadPrefs()
for i, v := range prefs.VLANs {
if parent, id := splitVLAN(v); id != 0 && moved[parent] != "" {
prefs.VLANs[i] = fmt.Sprintf("%s.%d", moved[parent], id)
}
}
slices.Sort(prefs.VLANs)
err = a.sys.savePrefs(prefs)
}
switch {
case err != nil:
msg = "Names not saved: " + err.Error()
case !renamedAny(rows):
msg = "Nothing to change."
default:
msg = "Saved. Apply, then reboot: the new names only exist after a restart."
}
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
func renamedAny(rows []Rename) bool {
for _, r := range rows {
if strings.TrimSpace(r.To) != r.From {
return true
}
}
return false
}
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
// UI's preferences: nothing is rebuilt until a WAN or LAN uses it.
func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
prefs := a.sys.loadPrefs()
msg := ""
switch {
case r.PostFormValue("add") != "":
parent := strings.TrimSpace(r.PostFormValue("parent"))
id, ok, perr := optionalInt(r.PostFormValue("id"), 1, 4094)
dev := fmt.Sprintf("%s.%d", parent, id)
switch {
case !ifaceRe.MatchString(parent) || strings.Contains(parent, "."):
msg = "Choose the interface the VLAN is on."
case perr != nil || !ok:
msg = "The VLAN ID must be a number from 1 to 4094."
case slices.Contains(prefs.VLANs, dev):
msg = dev + " exists already."
default:
prefs.VLANs = append(prefs.VLANs, dev)
slices.Sort(prefs.VLANs)
msg = "Added " + dev + "."
}
case r.PostFormValue("remove") != "":
dev := r.PostFormValue("remove")
for _, d := range a.devices(s, s.form()) {
if d.Name == dev && !d.Removable {
msg = dev + " is in use."
}
}
if msg == "" {
prefs.VLANs = slices.DeleteFunc(prefs.VLANs, func(v string) bool { return v == dev })
msg = "Removed " + dev + "."
}
}
if err := a.sys.savePrefs(prefs); err != nil {
msg = "Saving: " + err.Error()
}
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
// choices are the dropdown entries for a device: all known devices, plus the
// current value if it is not among them (an interface this machine lacks).
func choices(devices []Device, current string) []string {
var out []string
for _, d := range devices {
out = append(out, d.Name)
}
if current != "" && !slices.Contains(out, current) {
out = append(out, current)
}
return out
}
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, Flash: r.URL.Query().Get("msg")}
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: a.devices(s, f)}
a.render(w, "network", p)
}
func formFromRequest(r *http.Request) Form {
f := Form{
HostName: r.PostFormValue("hostName"),
Domain: r.PostFormValue("domain"),
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")),
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: a.devices(s, f)}
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
Theme 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, ", "), Theme: a.sys.loadPrefs().Theme}
}
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.")
}