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
567 lines
17 KiB
Go
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.")
|
|
}
|