// 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/- } 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.") }