diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index 94516c6..2cf57fc 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -226,7 +226,9 @@ in } } accept # wireguard" } ${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"} - ${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"} + ${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport { ${ + lib.concatMapStringsSep ", " toString ([ 22 ] ++ config.router.managementPorts) + } } accept"} } chain forward { diff --git a/modules/router/options.nix b/modules/router/options.nix index d1cf502..cf252e7 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -481,6 +481,16 @@ in ); }; + managementPorts = mkOption { + type = types.listOf types.port; + default = [ ]; + example = [ 443 ]; + description = '' + TCP ports on the router, besides SSH, reachable from LANs and tunnels + with `allowManagement` (the web UI adds its own). + ''; + }; + dns.domain = mkOption { type = types.nullOr types.str; default = null; diff --git a/modules/router/rollback.nix b/modules/router/rollback.nix index 76a89a1..ed93d78 100644 --- a/modules/router/rollback.nix +++ b/modules/router/rollback.nix @@ -53,6 +53,7 @@ let current=$(readlink -f /run/current-system) ${config.nix.package}/bin/nix-env -p ${profile} --set "$current" "$current/bin/switch-to-configuration" boot + ${cfg.onConfirm} echo "router: confirmed $current" ''; }; @@ -64,6 +65,12 @@ in default = true; description = "Install the commit-confirm helpers `router-arm`, `router-confirm` and `router-rollback`."; }; + onConfirm = lib.mkOption { + type = lib.types.lines; + default = ""; + internal = true; + description = "Shell commands `router-confirm` runs once the current system is confirmed."; + }; timeout = lib.mkOption { type = lib.types.str; default = "5min"; diff --git a/modules/settings/default.nix b/modules/settings/default.nix index 5d9b4e7..e93f6ad 100644 --- a/modules/settings/default.nix +++ b/modules/settings/default.nix @@ -26,9 +26,13 @@ }: let cfg = config.routerSettings; + profile = "/nix/var/nix/profiles/system"; settings = lib.importJSON cfg.file; system = settings.system or { }; + pending = "/run/router-pending-settings.json"; + confirmed = "${cfg.stateDirectory}/settings.confirmed.json"; + apply = pkgs.writeShellApplication { name = "router-apply"; runtimeInputs = [ @@ -37,18 +41,29 @@ let ]; text = '' timeout=''${1:-${config.router.rollback.timeout}} + # Remember what is being built: confirming records it (the web UI shows + # settings that differ from it as not yet applied). + building=$(mktemp /run/router-building.XXXXXX) + cp ${cfg.directory}/settings.json "$building" echo "router: building ${cfg.directory}" new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \ "path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel") if [ "$new" = "$(readlink -f /run/current-system)" ]; then echo "router: no changes" + if [ "$new" = "$(readlink -f ${profile})" ]; then + install -m 0600 "$building" ${confirmed} + fi + rm -f "$building" exit 0 fi + mv "$building" ${pending} router-arm "$timeout" echo "router: activating $new" "$new/bin/switch-to-configuration" test ''; }; + + webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { }; in { # Outside `router`: the settings file defines that whole tree. @@ -62,12 +77,34 @@ in default = "/var/lib/router/config"; description = "Where the configuration (flake and settings) lives on the device."; }; + stateDirectory = lib.mkOption { + type = lib.types.str; + default = "/var/lib/router"; + description = "Secrets and web UI state (password hash, certificate)."; + }; + webui = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Serve the web UI (HTTPS) to LANs and tunnels with `allowManagement`."; + }; + port = lib.mkOption { + type = lib.types.port; + default = 443; + }; + }; }; config = { - router = settings.router // { - enable = true; - }; + router = lib.mkMerge [ + (settings.router // { enable = true; }) + { + rollback.onConfirm = '' + if [ -e ${pending} ]; then mv ${pending} ${confirmed}; fi + ''; + } + (lib.mkIf cfg.webui.enable { managementPorts = [ cfg.webui.port ]; }) + ]; networking.hostName = system.hostName or "router"; time.timeZone = system.timeZone or "UTC"; @@ -79,7 +116,64 @@ in "flakes" ]; - environment.systemPackages = [ apply ]; + environment.systemPackages = [ + apply + webui + ]; + + # The web UI starts these rather than running anything itself, so a switch + # that restarts the UI does not cut them short; a switch must not restart + # or stop them either (router-apply is what runs the switch). + systemd.services.router-apply = { + description = "Build and activate the router configuration"; + restartIfChanged = false; + stopIfChanged = false; + path = [ "/run/current-system/sw" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = lib.getExe apply; + TimeoutStartSec = "1h"; + }; + }; + systemd.services.router-confirm = { + description = "Confirm the active router configuration"; + restartIfChanged = false; + stopIfChanged = false; + path = [ "/run/current-system/sw" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "/run/current-system/sw/bin/router-confirm"; + }; + }; + + systemd.services.router-webui = lib.mkIf cfg.webui.enable { + description = "Router web UI"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + path = [ config.systemd.package ]; + serviceConfig = { + ExecStart = "${lib.getExe webui} -listen :${toString cfg.webui.port} -config ${cfg.directory} -state ${cfg.stateDirectory}"; + Restart = "on-failure"; + # It edits settings and secrets and talks to systemd; nothing else. + ProtectSystem = "strict"; + ReadWritePaths = [ cfg.stateDirectory ]; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + "AF_NETLINK" + ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + }; + }; # Rebuilding on the device must work without internet (the uplink may be # what is being configured). What a changed configuration rebuilds is all diff --git a/pkgs/router-webui/auth.go b/pkgs/router-webui/auth.go new file mode 100644 index 0000000..f51cdc9 --- /dev/null +++ b/pkgs/router-webui/auth.go @@ -0,0 +1,113 @@ +package main + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" +) + +// Password hashes are stored as "pbkdf2-sha256$$$" +// (base64, no padding) in a file only root can read. +const ( + pbkdf2Iterations = 600_000 + pbkdf2KeyLen = 32 +) + +var b64 = base64.RawStdEncoding + +func hashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, pbkdf2KeyLen) + if err != nil { + return "", err + } + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iterations, b64.EncodeToString(salt), b64.EncodeToString(key)), nil +} + +var errBadHash = errors.New("unrecognised password hash") + +func checkPassword(stored, password string) (bool, error) { + parts := strings.Split(strings.TrimSpace(stored), "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false, errBadHash + } + iter, err := strconv.Atoi(parts[1]) + if err != nil || iter < 1 { + return false, errBadHash + } + salt, err1 := b64.DecodeString(parts[2]) + want, err2 := b64.DecodeString(parts[3]) + if err1 != nil || err2 != nil || len(want) == 0 { + return false, errBadHash + } + got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want)) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare(got, want) == 1, nil +} + +func randomToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +// Sessions live in memory: a restart of the UI (e.g. by a switch) logs +// everyone out, which is acceptable for a single-admin box. +type session struct { + csrf string + expires time.Time +} + +type sessions struct { + mu sync.Mutex + ttl time.Duration + m map[string]session +} + +func newSessions(ttl time.Duration) *sessions { + return &sessions{ttl: ttl, m: map[string]session{}} +} + +func (s *sessions) create() (id string, sess session) { + s.mu.Lock() + defer s.mu.Unlock() + id = randomToken() + sess = session{csrf: randomToken(), expires: time.Now().Add(s.ttl)} + s.m[id] = sess + return id, sess +} + +// get returns the session and extends it. +func (s *sessions) get(id string) (session, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.m[id] + if !ok || time.Now().After(sess.expires) { + delete(s.m, id) + return session{}, false + } + sess.expires = time.Now().Add(s.ttl) + s.m[id] = sess + return sess, true +} + +func (s *sessions) delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, id) +} diff --git a/pkgs/router-webui/auth_test.go b/pkgs/router-webui/auth_test.go new file mode 100644 index 0000000..dda6ea3 --- /dev/null +++ b/pkgs/router-webui/auth_test.go @@ -0,0 +1,19 @@ +package main + +import "testing" + +func TestPasswordHash(t *testing.T) { + h, err := hashPassword("correct horse") + if err != nil { + t.Fatal(err) + } + if ok, err := checkPassword(h, "correct horse"); !ok || err != nil { + t.Fatalf("right password rejected: %v", err) + } + if ok, _ := checkPassword(h, "wrong"); ok { + t.Fatal("wrong password accepted") + } + if _, err := checkPassword("plaintext", "plaintext"); err == nil { + t.Fatal("garbage hash accepted") + } +} diff --git a/pkgs/router-webui/go.mod b/pkgs/router-webui/go.mod new file mode 100644 index 0000000..8f28ae6 --- /dev/null +++ b/pkgs/router-webui/go.mod @@ -0,0 +1,3 @@ +module router-webui + +go 1.27 diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go new file mode 100644 index 0000000..779a38b --- /dev/null +++ b/pkgs/router-webui/main.go @@ -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/- + } + 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.") +} diff --git a/pkgs/router-webui/main_test.go b/pkgs/router-webui/main_test.go new file mode 100644 index 0000000..4b164fa --- /dev/null +++ b/pkgs/router-webui/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func newTestApp(t *testing.T) (*app, System) { + t.Helper() + dir := t.TempDir() + sys := System{ConfigDir: filepath.Join(dir, "config"), StateDir: dir} + os.MkdirAll(sys.ConfigDir, 0o700) + os.WriteFile(sys.settingsPath(), []byte(sample), 0o600) + h, _ := hashPassword("hunter2hunter2") + os.WriteFile(sys.passwordPath(), []byte(h), 0o600) + return newApp(sys), sys +} + +func do(h http.Handler, method, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { + var req *http.Request + if form != nil { + req = httptest.NewRequest(method, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req = httptest.NewRequest(method, path, nil) + } + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestLoginAndSave(t *testing.T) { + a, sys := newTestApp(t) + h := a.routes() + + if rec := do(h, "GET", "/network", nil, nil); rec.Code != http.StatusSeeOther { + t.Fatalf("unauthenticated access: %d", rec.Code) + } + if rec := do(h, "POST", "/login", url.Values{"password": {"nope"}}, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong password: %d", rec.Code) + } + rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil) + if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) == 0 { + t.Fatalf("login failed: %d", rec.Code) + } + cookie := rec.Result().Cookies()[0] + + page := do(h, "GET", "/network", nil, cookie).Body.String() + m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page) + if m == nil { + t.Fatalf("no CSRF token in:\n%s", page) + } + form := url.Values{ + "hostName": {"gw"}, "wan.interface": {"eth1"}, "wan.type": {"dhcp"}, + "lans": {"1"}, "lan.0.name": {"main"}, "lan.0.interface": {"eth2"}, + "lan.0.address": {"192.168.10.1"}, "lan.0.internet": {"on"}, "lan.0.dhcp": {"on"}, + "lan.0.poolStart": {"192.168.10.100"}, "lan.0.poolEnd": {"192.168.10.120"}, + } + if rec := do(h, "POST", "/network", form, cookie); rec.Code != http.StatusForbidden { + t.Fatalf("POST without CSRF token: %d", rec.Code) + } + form.Set("csrf", m[1]) + if rec := do(h, "POST", "/network", form, cookie); rec.Code != http.StatusSeeOther { + t.Fatalf("save: %d\n%s", rec.Code, rec.Body) + } + s, _ := loadSettings(sys.settingsPath()) + if str(s, "system", "hostName") != "gw" || str(s, "router", "lans", "main", "dhcp", "pool", "end") != "192.168.10.120" { + t.Fatalf("not saved: %v", s) + } + if str(s, "router", "lans", "main", "dhcp", "reservations", "nas", "address") != "192.168.10.5" { + t.Fatal("the form dropped the reservation") + } + + req := httptest.NewRequest("POST", "/advanced", strings.NewReader(url.Values{"csrf": {m[1]}, "settings": {"{}"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://evil.example") + req.AddCookie(cookie) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-origin POST: %d", rec.Code) + } +} diff --git a/pkgs/router-webui/package.nix b/pkgs/router-webui/package.nix new file mode 100644 index 0000000..c7aa984 --- /dev/null +++ b/pkgs/router-webui/package.nix @@ -0,0 +1,20 @@ +{ lib, buildGo127Module }: +buildGo127Module { + pname = "router-webui"; + version = "0.1.0"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./go.mod + (lib.fileset.fileFilter (f: f.hasExt "go") ./.) + ./templates + ./static + ]; + }; + # Standard library only. + vendorHash = null; + meta = { + description = "Web UI for a nixos-router configured by settings.json"; + mainProgram = "router-webui"; + }; +} diff --git a/pkgs/router-webui/settings.go b/pkgs/router-webui/settings.go new file mode 100644 index 0000000..e4f7683 --- /dev/null +++ b/pkgs/router-webui/settings.go @@ -0,0 +1,360 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/netip" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" +) + +// Settings is settings.json kept generic: the forms change the keys they know +// and everything else (WireGuard, port forwards, ...) survives untouched. +type Settings map[string]any + +func loadSettings(path string) (Settings, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return parseSettings(data) +} + +func parseSettings(data []byte) (Settings, error) { + var s Settings + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("settings are not valid JSON: %w", err) + } + if s == nil { + return nil, fmt.Errorf("settings must be a JSON object") + } + return s, nil +} + +func (s Settings) marshal() ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(s); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// writeFileAtomic replaces path so that readers never see a partial file. +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +func (s Settings) save(path string) error { + data, err := s.marshal() + if err != nil { + return err + } + return writeFileAtomic(path, data, 0o600) +} + +// obj returns the object at key, creating it if needed. +func obj(m map[string]any, key string) map[string]any { + if v, ok := m[key].(map[string]any); ok { + return v + } + v := map[string]any{} + m[key] = v + return v +} + +func str(m map[string]any, keys ...string) string { + for _, k := range keys[:len(keys)-1] { + next, ok := m[k].(map[string]any) + if !ok { + return "" + } + m = next + } + switch v := m[keys[len(keys)-1]].(type) { + case string: + return v + case float64: // as decoded from JSON + return strconv.FormatFloat(v, 'f', -1, 64) + case int: // as set by apply + return strconv.Itoa(v) + } + return "" +} + +func boolean(m map[string]any, def bool, keys ...string) bool { + for _, k := range keys[:len(keys)-1] { + next, ok := m[k].(map[string]any) + if !ok { + return def + } + m = next + } + if v, ok := m[keys[len(keys)-1]].(bool); ok { + return v + } + return def +} + +// Form is what the network page edits. +type Form struct { + HostName string + WAN WANForm + LANs []LANForm +} + +type WANForm struct { + Interface string + Type string // "dhcp" or "pppoe" + PPPoEUser string + PPPoEVLAN string + PPPoEPassword string // write-only: empty keeps the stored one + PasswordSet bool +} + +type LANForm struct { + Name string + Interface string + VLAN string + Address string + Prefix string + Internet bool + DHCP bool + PoolStart string + PoolEnd string + Remove bool +} + +func (s Settings) form() Form { + router, _ := s["router"].(map[string]any) + if router == nil { + router = map[string]any{} + } + f := Form{ + HostName: str(s, "system", "hostName"), + WAN: WANForm{ + Interface: str(router, "wan", "interface"), + Type: str(router, "wan", "ipv4", "type"), + PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"), + PPPoEVLAN: str(router, "wan", "ipv4", "pppoe", "vlan"), + }, + } + if f.WAN.Type == "" { + f.WAN.Type = "dhcp" + } + f.WAN.PasswordSet = str(router, "wan", "ipv4", "pppoe", "passwordFile") != "" + lans, _ := router["lans"].(map[string]any) + names := make([]string, 0, len(lans)) + for name := range lans { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + l, _ := lans[name].(map[string]any) + if l == nil { + continue + } + f.LANs = append(f.LANs, LANForm{ + Name: name, + Interface: str(l, "interface"), + VLAN: str(l, "vlan"), + Address: str(l, "ipv4", "address"), + Prefix: str(l, "ipv4", "prefixLength"), + Internet: boolean(l, false, "internet"), + DHCP: boolean(l, true, "dhcp", "enable"), + PoolStart: str(l, "dhcp", "pool", "start"), + PoolEnd: str(l, "dhcp", "pool", "end"), + }) + } + return f +} + +var ( + nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`) + ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`) +) + +// FormErrors maps a field to what is wrong with it. +type FormErrors map[string]string + +func (e FormErrors) Error() string { + keys := make([]string, 0, len(e)) + for k := range e { + keys = append(keys, k) + } + slices.Sort(keys) + var b strings.Builder + for _, k := range keys { + fmt.Fprintf(&b, "%s: %s; ", k, e[k]) + } + return strings.TrimSuffix(b.String(), "; ") +} + +func optionalInt(v string, lo, hi int) (int, bool, error) { + v = strings.TrimSpace(v) + if v == "" { + return 0, false, nil + } + n, err := strconv.Atoi(v) + if err != nil || n < lo || n > hi { + return 0, false, fmt.Errorf("must be a number from %d to %d", lo, hi) + } + return n, true, nil +} + +func ipv4(v string) (netip.Addr, bool) { + a, err := netip.ParseAddr(strings.TrimSpace(v)) + return a, err == nil && a.Is4() +} + +// apply writes the form into the settings. Only basic checks happen here; the +// build on the device validates the rest (and the router's assertions). +// It returns the PPPoE password to store, if one was entered. +func (s Settings) apply(f Form, passwordFile string) (string, error) { + errs := FormErrors{} + system := obj(s, "system") + router := obj(s, "router") + wan := obj(router, "wan") + + if f.HostName = strings.TrimSpace(f.HostName); f.HostName != "" { + if !regexp.MustCompile(`^[a-zA-Z0-9-]{1,63}$`).MatchString(f.HostName) { + errs["hostName"] = "letters, digits and dashes only" + } + system["hostName"] = f.HostName + } + + if !ifaceRe.MatchString(f.WAN.Interface) { + errs["wan.interface"] = "an interface name is required" + } + wan["interface"] = f.WAN.Interface + ipv4cfg := obj(wan, "ipv4") + switch f.WAN.Type { + case "dhcp": + ipv4cfg["type"] = "dhcp" + case "pppoe": + ipv4cfg["type"] = "pppoe" + pppoe := obj(ipv4cfg, "pppoe") + pppoe["username"] = strings.TrimSpace(f.WAN.PPPoEUser) + if pppoe["username"] == "" { + errs["wan.pppoe.username"] = "required for PPPoE" + } + if vlan, ok, err := optionalInt(f.WAN.PPPoEVLAN, 1, 4094); err != nil { + errs["wan.pppoe.vlan"] = err.Error() + } else if ok { + pppoe["vlan"] = vlan + } else { + delete(pppoe, "vlan") + } + if f.WAN.PPPoEPassword != "" { + pppoe["passwordFile"] = passwordFile + } else if str(pppoe, "passwordFile") == "" { + errs["wan.pppoe.password"] = "required for PPPoE" + } + default: + errs["wan.type"] = "must be dhcp or pppoe" + } + + lans := obj(router, "lans") + keep := map[string]bool{} + for i, lf := range f.LANs { + field := func(n string) string { return fmt.Sprintf("lans[%d].%s", i, n) } + lf.Name = strings.TrimSpace(lf.Name) + if lf.Name == "" && lf.Interface == "" && lf.Address == "" { + continue // the empty "add a LAN" row + } + if !nameRe.MatchString(lf.Name) { + errs[field("name")] = "lowercase letters, digits and dashes, starting with a letter" + continue + } + if keep[lf.Name] { + errs[field("name")] = "used twice" + continue + } + if lf.Remove { + continue + } + keep[lf.Name] = true + l := obj(lans, lf.Name) + if !ifaceRe.MatchString(lf.Interface) { + errs[field("interface")] = "an interface name is required" + } + l["interface"] = lf.Interface + if vlan, ok, err := optionalInt(lf.VLAN, 1, 4094); err != nil { + errs[field("vlan")] = err.Error() + } else if ok { + l["vlan"] = vlan + } else { + delete(l, "vlan") + } + l["internet"] = lf.Internet + v4 := obj(l, "ipv4") + if _, ok := ipv4(lf.Address); !ok { + errs[field("address")] = "an IPv4 address is required" + } + v4["address"] = strings.TrimSpace(lf.Address) + if p, ok, err := optionalInt(lf.Prefix, 8, 30); err != nil { + errs[field("prefix")] = err.Error() + } else if ok { + v4["prefixLength"] = p + } else { + delete(v4, "prefixLength") + } + dhcp := obj(l, "dhcp") + dhcp["enable"] = lf.DHCP + if lf.DHCP { + start, ok1 := ipv4(lf.PoolStart) + end, ok2 := ipv4(lf.PoolEnd) + if !ok1 { + errs[field("poolStart")] = "an IPv4 address is required" + } + if !ok2 { + errs[field("poolEnd")] = "an IPv4 address is required" + } + if ok1 && ok2 && end.Less(start) { + errs[field("poolEnd")] = "must not be before the start" + } + pool := obj(dhcp, "pool") + pool["start"] = strings.TrimSpace(lf.PoolStart) + pool["end"] = strings.TrimSpace(lf.PoolEnd) + } + } + for name := range lans { + if !keep[name] { + delete(lans, name) + } + } + if len(keep) == 0 { + errs["lans"] = "at least one LAN is required" + } + if len(errs) > 0 { + return "", errs + } + return f.WAN.PPPoEPassword, nil +} diff --git a/pkgs/router-webui/settings_test.go b/pkgs/router-webui/settings_test.go new file mode 100644 index 0000000..ea9597d --- /dev/null +++ b/pkgs/router-webui/settings_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +const sample = `{ + "system": { "hostName": "router", "stateVersion": "26.11" }, + "router": { + "wan": { "interface": "eth1" }, + "lans": { + "main": { + "interface": "eth2", "internet": true, + "ipv4": { "address": "192.168.10.1" }, + "dhcp": { "pool": { "start": "192.168.10.100", "end": "192.168.10.199" }, + "reservations": { "nas": { "mac": "aa:bb:cc:dd:ee:ff", "address": "192.168.10.5" } } }, + "ipv6": { "address": "fd00::1/64" } + } + }, + "wireguard": { "vpn": { "listenPort": 51820 } } + } +}` + +func mustParse(t *testing.T) Settings { + t.Helper() + s, err := parseSettings([]byte(sample)) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestFormReadsSettings(t *testing.T) { + f := mustParse(t).form() + if f.HostName != "router" || f.WAN.Interface != "eth1" || f.WAN.Type != "dhcp" { + t.Fatalf("unexpected form: %+v", f) + } + if len(f.LANs) != 1 || f.LANs[0].Name != "main" || !f.LANs[0].DHCP || !f.LANs[0].Internet || + f.LANs[0].PoolEnd != "192.168.10.199" { + t.Fatalf("unexpected LANs: %+v", f.LANs) + } +} + +func TestApplyKeepsUnknownKeys(t *testing.T) { + s := mustParse(t) + f := s.form() + f.LANs[0].PoolEnd = "192.168.10.150" + if _, err := s.apply(f, "/var/lib/router/pppoe-password"); err != nil { + t.Fatal(err) + } + out, _ := s.marshal() + for _, want := range []string{`"192.168.10.150"`, `"reservations"`, `"fd00::1/64"`, `"wireguard"`, `"stateVersion"`} { + if !strings.Contains(string(out), want) { + t.Errorf("%s missing after apply:\n%s", want, out) + } + } +} + +func TestApplyPPPoE(t *testing.T) { + s := mustParse(t) + f := s.form() + f.WAN.Type = "pppoe" + f.WAN.PPPoEUser = "user@isp" + f.WAN.PPPoEVLAN = "7" + if _, err := s.apply(f, "/pw"); err == nil { + t.Fatal("PPPoE without any password was accepted") + } + s = mustParse(t) + f.WAN.PPPoEPassword = "secret" + pw, err := s.apply(f, "/pw") + if err != nil || pw != "secret" { + t.Fatalf("apply: %q %v", pw, err) + } + if got := str(s, "router", "wan", "ipv4", "pppoe", "passwordFile"); got != "/pw" { + t.Errorf("passwordFile = %q", got) + } + out, _ := s.marshal() + if strings.Contains(string(out), "secret") { + t.Error("the password ended up in the settings") + } + if !strings.Contains(string(out), `"vlan": 7`) { + t.Errorf("vlan is not a number:\n%s", out) + } +} + +func TestApplyValidates(t *testing.T) { + s := mustParse(t) + f := s.form() + f.LANs[0].Address = "not-an-ip" + f.LANs[0].VLAN = "5000" + f.LANs = append(f.LANs, LANForm{Name: "Bad Name", Interface: "eth3", Address: "10.0.0.1"}) + _, err := s.apply(f, "/pw") + var fe FormErrors + if !errors.As(err, &fe) { + t.Fatalf("expected form errors, got %v", err) + } + for _, k := range []string{"lans[0].address", "lans[0].vlan", "lans[1].name"} { + if fe[k] == "" { + t.Errorf("no error for %s: %v", k, fe) + } + } +} + +func TestApplyAddsAndRemovesLANs(t *testing.T) { + s := mustParse(t) + f := s.form() + f.LANs = append(f.LANs, LANForm{Name: "guest", Interface: "eth2", VLAN: "20", Address: "192.168.30.1", + DHCP: true, PoolStart: "192.168.30.100", PoolEnd: "192.168.30.199"}) + if _, err := s.apply(f, "/pw"); err != nil { + t.Fatal(err) + } + if got := s.form().LANs; len(got) != 2 || got[0].Name != "guest" || got[0].VLAN != "20" { + t.Fatalf("after adding: %+v", got) + } + f = s.form() + f.LANs[0].Remove = true + if _, err := s.apply(f, "/pw"); err != nil { + t.Fatal(err) + } + if got := s.form().LANs; len(got) != 1 || got[0].Name != "main" { + t.Fatalf("after removing: %+v", got) + } + f = s.form() + f.LANs[0].Remove = true + if _, err := s.apply(f, "/pw"); err == nil { + t.Fatal("removing the last LAN was accepted") + } +} diff --git a/pkgs/router-webui/static/style.css b/pkgs/router-webui/static/style.css new file mode 100644 index 0000000..e89332d --- /dev/null +++ b/pkgs/router-webui/static/style.css @@ -0,0 +1,37 @@ +:root { --fg: #1d2125; --muted: #5b636b; --bg: #fff; --line: #d6dade; --accent: #2457c5; --warn: #fff4d6; --bad: #b3261e; --ok: #e6f2e9; } +@media (prefers-color-scheme: dark) { + :root { --fg: #e4e7ea; --muted: #9aa2aa; --bg: #15181b; --line: #30363c; --accent: #7aa2ff; --warn: #3a3218; --bad: #ff8a80; --ok: #1d3324; } +} +* { box-sizing: border-box; } +body { margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); } +header { display: flex; gap: 1.5rem; align-items: center; padding: .75rem 1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; } +nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } +nav form { display: inline; } +a { color: var(--accent); } +main { max-width: 72rem; margin: 0 auto; padding: 1rem; } +h1 { font-size: 1.4rem; margin: .5rem 0 1rem; } +h2 { font-size: 1.1rem; margin: 0 0 .5rem; } +section { margin: 0 0 1.25rem; } +section.busy, section.pending { padding: .75rem 1rem; border-radius: 6px; background: var(--warn); } +.flash { padding: .5rem .75rem; background: var(--ok); border-radius: 6px; } +.error, .field-error { color: var(--bad); } +.field-error { font-size: .9em; } +.hint { color: var(--muted); font-size: .9em; margin: .25rem 0; } +dl { display: grid; grid-template-columns: max-content 1fr; gap: .25rem 1rem; } +dt { color: var(--muted); } +dd { margin: 0; overflow-wrap: anywhere; } +fieldset { border: 1px solid var(--line); border-radius: 6px; margin: 0 0 1rem; padding: .75rem 1rem; overflow-x: auto; } +label { display: inline-flex; flex-direction: column; margin: 0 1rem .5rem 0; font-size: .9em; color: var(--muted); } +input, select, textarea, button { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: .3rem .5rem; } +textarea { width: 100%; font-family: ui-monospace, monospace; font-size: 13px; } +table { border-collapse: collapse; } +th { text-align: left; font-weight: 500; color: var(--muted); font-size: .85em; padding: 0 .3rem; } +td { padding: .15rem .3rem; } +button { background: var(--accent); color: var(--bg); border-color: var(--accent); cursor: pointer; padding: .4rem 1rem; } +button.secondary { background: transparent; color: var(--accent); } +button.link { background: none; border: none; color: var(--accent); padding: 0; text-decoration: underline; } +.actions { display: flex; gap: .75rem; flex-wrap: wrap; } +.narrow { max-width: 22rem; display: flex; flex-direction: column; gap: .75rem; } +.narrow label { margin: 0; } +pre.log { background: color-mix(in srgb, var(--line) 35%, transparent); padding: .75rem; border-radius: 6px; overflow-x: auto; font-size: 12.5px; max-height: 30rem; } +code { font-size: .9em; overflow-wrap: anywhere; } diff --git a/pkgs/router-webui/system.go b/pkgs/router-webui/system.go new file mode 100644 index 0000000..de185b5 --- /dev/null +++ b/pkgs/router-webui/system.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" +) + +// System is how the UI acts on the box. Everything that changes the running +// system goes through systemd units (router-apply, router-confirm), so a +// switch that restarts the UI cannot cut an apply short. +type System struct { + ConfigDir string // the configuration flake, settings.json in it + StateDir string // /var/lib/router: secrets, UI state +} + +func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") } +func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") } +func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") } +func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") } + +func command(name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func unitProperty(unit, prop string) string { + out, _ := command("systemctl", "show", "--value", "-p", prop, unit) + return out +} + +// Status is what the overview shows. +type Status struct { + Current string + Confirmed string + Applying bool + ApplyResult string // "success", "exit-code", ... of the last apply ("" if none) + Log string + RollbackIn time.Duration // > 0 while a change awaits confirmation + Unapplied bool // settings.json differs from the confirmed settings + ConfirmRunning bool +} + +func (s System) Status() Status { + st := Status{} + st.Current, _ = filepath.EvalSymlinks("/run/current-system") + st.Confirmed, _ = filepath.EvalSymlinks("/nix/var/nix/profiles/system") + state := unitProperty("router-apply.service", "ActiveState") + st.Applying = state == "activating" + if inv := unitProperty("router-apply.service", "InvocationID"); inv != "" { + st.ApplyResult = unitProperty("router-apply.service", "Result") + if st.Applying { + st.ApplyResult = "" + } + st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200", + "_SYSTEMD_INVOCATION_ID="+inv) + } + st.ConfirmRunning = unitProperty("router-confirm.service", "ActiveState") == "activating" + st.RollbackIn = rollbackIn() + cur, err1 := os.ReadFile(s.settingsPath()) + conf, err2 := os.ReadFile(s.confirmedPath()) + st.Unapplied = err1 == nil && err2 == nil && !sameJSON(cur, conf) + return st +} + +func sameJSON(a, b []byte) bool { + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return bytes.Equal(a, b) + } + xa, _ := json.Marshal(x) + ya, _ := json.Marshal(y) + return bytes.Equal(xa, ya) +} + +// rollbackIn is the time left on router-rollback.timer, armed by router-apply. +func rollbackIn() time.Duration { + out, err := command("systemctl", "list-timers", "--output=json", "router-rollback.timer") + if err != nil { + return 0 + } + var timers []struct { + Next uint64 `json:"next"` + Unit string `json:"unit"` + } + if json.Unmarshal([]byte(out), &timers) != nil { + return 0 + } + for _, t := range timers { + if t.Unit == "router-rollback.timer" && t.Next > 0 { + left := time.Until(time.UnixMicro(int64(t.Next))) + if left > 0 { + return left + } + } + } + return 0 +} + +func (s System) Apply() error { + _, err := command("systemctl", "start", "--no-block", "router-apply.service") + return err +} + +func (s System) Confirm() error { + _, err := command("systemctl", "start", "--no-block", "router-confirm.service") + return err +} + +// Discard puts back the settings of the confirmed system. +func (s System) Discard() error { + data, err := os.ReadFile(s.confirmedPath()) + if err != nil { + return err + } + return writeFileAtomic(s.settingsPath(), data, 0o600) +} + +// Interfaces lists the physical-looking network devices, for the forms. +func Interfaces() []string { + entries, _ := os.ReadDir("/sys/class/net") + var names []string + for _, e := range entries { + n := e.Name() + if n == "lo" { + continue + } + // Skip virtual devices (VLANs, PPP, WireGuard): they are not chosen + // as a LAN's or the uplink's interface. + if _, err := os.Stat(filepath.Join("/sys/class/net", n, "device")); err != nil { + continue + } + names = append(names, n) + } + slices.Sort(names) + return names +} diff --git a/pkgs/router-webui/templates/advanced.html b/pkgs/router-webui/templates/advanced.html new file mode 100644 index 0000000..9b273fe --- /dev/null +++ b/pkgs/router-webui/templates/advanced.html @@ -0,0 +1,9 @@ +{{define "content"}} +

The complete settings: the router options of nixos-router as JSON, plus system. + They are checked when applied; nothing changes until then.

+
+ + + +
+{{end}} diff --git a/pkgs/router-webui/templates/layout.html b/pkgs/router-webui/templates/layout.html new file mode 100644 index 0000000..2d5752a --- /dev/null +++ b/pkgs/router-webui/templates/layout.html @@ -0,0 +1,29 @@ + + + + + +{{if .Refresh}}{{end}} +{{.Title}} · router + + + +
+ router + {{if .LoggedIn}} + + {{end}} +
+
+

{{.Title}}

+ {{if .Flash}}

{{.Flash}}

{{end}} + {{if .Error}}

{{.Error}}

{{end}} + {{template "content" .}} +
+ + diff --git a/pkgs/router-webui/templates/login.html b/pkgs/router-webui/templates/login.html new file mode 100644 index 0000000..2b2dc89 --- /dev/null +++ b/pkgs/router-webui/templates/login.html @@ -0,0 +1,6 @@ +{{define "content"}} +
+ + +
+{{end}} diff --git a/pkgs/router-webui/templates/network.html b/pkgs/router-webui/templates/network.html new file mode 100644 index 0000000..2163303 --- /dev/null +++ b/pkgs/router-webui/templates/network.html @@ -0,0 +1,64 @@ +{{define "content"}} +{{with .Data}} +{{$e := .Errors}} +
+ + {{range .Interfaces}} + +
+ System + + {{with index $e "hostName"}}{{.}}{{end}} +
+ +
+ Uplink (WAN) + + {{with index $e "wan.interface"}}{{.}}{{end}} + +
+

PPPoE only:

+ + {{with index $e "wan.pppoe.username"}}{{.}}{{end}} + + {{with index $e "wan.pppoe.password"}}{{.}}{{end}} + + {{with index $e "wan.pppoe.vlan"}}{{.}}{{end}} +
+
+ +
+ LANs + {{with index $e "lans"}}

{{.}}

{{end}} + + + + {{range $i, $l := .Form.LANs}} + + + + + + + + + + + + + {{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}{{end}}{{end}} + {{end}} +
NameInterfaceVLANAddressPrefixInternetDHCPPool startPool endRemove
{{if $l.Name}}{{end}}
{{$f}}: {{.}}
+

Fill in the last row to add a LAN. Other LAN settings (IPv6, reservations, zones) are kept; edit them under Advanced.

+
+ + +
+{{end}} +{{end}} diff --git a/pkgs/router-webui/templates/overview.html b/pkgs/router-webui/templates/overview.html new file mode 100644 index 0000000..aecd2c4 --- /dev/null +++ b/pkgs/router-webui/templates/overview.html @@ -0,0 +1,37 @@ +{{define "content"}} +{{$s := .Data}} +
+
+
Running system
{{short $s.Current}}
+
Confirmed system
{{short $s.Confirmed}}
+
+
+ +{{if $s.Applying}} +

Applying…

Building the new configuration. This page refreshes on its own.

+{{else if $s.ConfirmRunning}} +

Confirming…

+{{else if gt (seconds $s.RollbackIn) 0}} +
+

Waiting for confirmation

+

The new configuration is active. Unless you confirm it within {{duration $s.RollbackIn}}, + the router goes back to the confirmed system on its own.

+
+
+{{else}} +
+ {{if $s.Unapplied}}

The settings have changes that are not part of the confirmed system.

{{else}}

The settings match the confirmed system.

{{end}} +
+
+ {{if $s.Unapplied}}
{{end}} +
+
+{{end}} + +{{if $s.Log}} +
+

Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}

+
{{$s.Log}}
+
+{{end}} +{{end}} diff --git a/tests/apply.nix b/tests/apply.nix index c69b722..029bf6a 100644 --- a/tests/apply.nix +++ b/tests/apply.nix @@ -3,7 +3,9 @@ # without network, then # - rebuilding the unchanged settings gives exactly the installed system, # - a changed setting is built offline, activated and confirmed, -# - an unconfirmed change is rolled back. +# - an unconfirmed change is rolled back, +# - and the same through the web UI from a LAN client: discard the +# unapplied change, edit the DHCP pool, apply, confirm. { self, pkgs }: let inherit (pkgs) lib; @@ -75,6 +77,19 @@ pkgs.testers.runNixOSTest { nix.settings.substituters = lib.mkForce [ ]; }; + client = + { lib, ... }: + { + imports = [ common.tools ]; + virtualisation.vlans = [ 2 ]; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ + { + address = "192.168.10.50"; + prefixLength = 24; + } + ]; + }; + target = { imports = [ sharedDisk ]; # Evaluating a NixOS system takes memory. @@ -136,5 +151,50 @@ pkgs.testers.runNixOSTest { assert pool_end() == "192.168.10.120" target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {confirmed}", timeout=120) assert pool_end() == "192.168.10.150" + + ui = "https://192.168.10.1" + + def curl(args): + return client.succeed(f"curl -sk -b /tmp/jar -c /tmp/jar {args}") + + def csrf(path): + return curl(f"{ui}{path} | grep -o 'name=\"csrf\" value=\"[^\"]*' | head -1 | cut -d'\"' -f4").strip() + + with subtest("the web UI is reachable from the LAN and wants the password"): + target.succeed("echo hunter2hunter2 | router-webui passwd /var/lib/router/webui-password") + target.wait_for_unit("router-webui.service") + client.start() + client.wait_until_succeeds(f"curl -sk -o /dev/null {ui}/login", timeout=60) + assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=wrong {ui}/login") == "401" + assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "303" + + with subtest("the web UI shows the rolled back change as unapplied and discards it"): + page = curl(f"{ui}/") + assert "not part of the confirmed system" in page, page + token = csrf("/") + curl(f"-d csrf={token} {ui}/discard") + assert "192.168.10.150" in target.succeed("cat /var/lib/router/config/settings.json") + assert "match the confirmed system" in curl(f"{ui}/") + + with subtest("a change made in the web UI is applied and confirmed"): + token = csrf("/network") + form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in { + "csrf": token, "hostName": "router", "wan.interface": "eth1", "wan.type": "dhcp", + "lans": "1", "lan.0.name": "main", "lan.0.interface": "eth2", "lan.0.address": "192.168.10.1", + "lan.0.internet": "on", "lan.0.dhcp": "on", + "lan.0.poolStart": "192.168.10.100", "lan.0.poolEnd": "192.168.10.130", + }.items()) + assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403" + assert curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}/network") == "303" + curl(f"-d csrf={csrf('/')} {ui}/apply") + target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-apply.service) = inactive" + " && test $(systemctl show -p Result --value router-apply.service) = success", timeout=600) + assert pool_end() == "192.168.10.130" + assert "Waiting for confirmation" in curl(f"{ui}/") + curl(f"-d csrf={csrf('/')} {ui}/confirm") + target.wait_until_fails("systemctl is-active router-rollback.timer", timeout=60) + target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive") + assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current() + assert "match the confirmed system" in curl(f"{ui}/") ''; }