Add a web UI for settings-configured routers
router-webui (Go, standard library only) edits settings.json: host name, WAN (DHCP or PPPoE), LANs with DHCP, and the raw JSON for the rest; keys the forms do not know are kept. It applies and confirms by starting router-apply.service and router-confirm.service, which a switch never restarts, and shows the build log and the rollback countdown. HTTPS with a self-signed certificate, a PBKDF2 password hash, CSRF tokens and an Origin check; reachable from management LANs only (new router.managementPorts). router-apply records the settings it built and router-confirm keeps them as settings.confirmed.json (new rollback.onConfirm hook), so the UI can tell unapplied changes and discard them. checks.apply now also logs in from a LAN client, discards the change left by the rollback, edits the DHCP pool, applies and confirms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
// 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]
|
||||
// router-webui passwd FILE (reads the new password from stdin)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"embed"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
||||
if err := setPassword(os.Args[2]); 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)")
|
||||
flag.Parse()
|
||||
|
||||
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)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: newApp(sys).routes(),
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("listening on %s", *listen)
|
||||
log.Fatal(srv.ListenAndServeTLS("", ""))
|
||||
}
|
||||
|
||||
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
|
||||
sessions *sessions
|
||||
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{
|
||||
"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 },
|
||||
}
|
||||
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"))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
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))
|
||||
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("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
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.pages[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) 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func sameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
return err == nil && u.Host == r.Host
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")}
|
||||
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
|
||||
}
|
||||
|
||||
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()}
|
||||
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"),
|
||||
PPPoEVLAN: r.PostFormValue("wan.pppoe.vlan"),
|
||||
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",
|
||||
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()}
|
||||
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.")
|
||||
}
|
||||
|
||||
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)}
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
}
|
||||
a.render(w, "advanced", p)
|
||||
}
|
||||
|
||||
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()})
|
||||
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()})
|
||||
return
|
||||
}
|
||||
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||
}
|
||||
Reference in New Issue
Block a user