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:
2026-10-28 22:43:58 +01:00
co-authored by Claude Opus 5.5
parent 2c8d5562f9
commit 753459f5d7
20 changed files with 1685 additions and 6 deletions
+3 -1
View File
@@ -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 {
+10
View File
@@ -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;
+7
View File
@@ -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";
+98 -4
View File
@@ -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
+113
View File
@@ -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$<iterations>$<salt>$<key>"
// (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)
}
+19
View File
@@ -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")
}
}
+3
View File
@@ -0,0 +1,3 @@
module router-webui
go 1.27
+442
View File
@@ -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.")
}
+92
View File
@@ -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)
}
}
+20
View File
@@ -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";
};
}
+360
View File
@@ -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
}
+130
View File
@@ -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")
}
}
+37
View File
@@ -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; }
+145
View File
@@ -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
}
@@ -0,0 +1,9 @@
{{define "content"}}
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
They are checked when applied; nothing changes until then.</p>
<form method="post" action="/advanced">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<textarea name="settings" rows="30" spellcheck="false">{{.Data}}</textarea>
<button>Save</button>
</form>
{{end}}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{if .Refresh}}<meta http-equiv="refresh" content="{{.Refresh}}">{{end}}
<title>{{.Title}} · router</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<strong>router</strong>
{{if .LoggedIn}}
<nav>
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/advanced">Advanced</a>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
{{end}}
</header>
<main>
<h1>{{.Title}}</h1>
{{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{template "content" .}}
</main>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{{define "content"}}
<form method="post" action="/login" class="narrow">
<label>Password <input type="password" name="password" autocomplete="current-password" autofocus required></label>
<button>Log in</button>
</form>
{{end}}
+64
View File
@@ -0,0 +1,64 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<form method="post" action="/network">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<datalist id="ifaces">{{range .Interfaces}}<option value="{{.}}">{{end}}</datalist>
<fieldset>
<legend>System</legend>
<label>Host name <input name="hostName" value="{{.Form.HostName}}"></label>
{{with index $e "hostName"}}<span class="field-error">{{.}}</span>{{end}}
</fieldset>
<fieldset>
<legend>Uplink (WAN)</legend>
<label>Interface <input name="wan.interface" list="ifaces" value="{{.Form.WAN.Interface}}" required></label>
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
<label>Type
<select name="wan.type">
<option value="dhcp" {{if eq .Form.WAN.Type "dhcp"}}selected{{end}}>DHCP</option>
<option value="pppoe" {{if eq .Form.WAN.Type "pppoe"}}selected{{end}}>PPPoE</option>
</select>
</label>
<div class="sub">
<p class="hint">PPPoE only:</p>
<label>Username <input name="wan.pppoe.username" value="{{.Form.WAN.PPPoEUser}}" autocomplete="off"></label>
{{with index $e "wan.pppoe.username"}}<span class="field-error">{{.}}</span>{{end}}
<label>Password <input type="password" name="wan.pppoe.password" autocomplete="new-password"
placeholder="{{if .Form.WAN.PasswordSet}}stored — leave empty to keep{{end}}"></label>
{{with index $e "wan.pppoe.password"}}<span class="field-error">{{.}}</span>{{end}}
<label>VLAN <input name="wan.pppoe.vlan" value="{{.Form.WAN.PPPoEVLAN}}" inputmode="numeric" placeholder="untagged"></label>
{{with index $e "wan.pppoe.vlan"}}<span class="field-error">{{.}}</span>{{end}}
</div>
</fieldset>
<fieldset>
<legend>LANs</legend>
{{with index $e "lans"}}<p class="field-error">{{.}}</p>{{end}}
<input type="hidden" name="lans" value="{{len .Form.LANs}}">
<table>
<tr><th>Name</th><th>Interface</th><th>VLAN</th><th>Address</th><th>Prefix</th><th>Internet</th><th>DHCP</th><th>Pool start</th><th>Pool end</th><th>Remove</th></tr>
{{range $i, $l := .Form.LANs}}
<tr>
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
<td><input name="lan.{{$i}}.interface" list="ifaces" value="{{$l.Interface}}" size="8"></td>
<td><input name="lan.{{$i}}.vlan" value="{{$l.VLAN}}" size="4" inputmode="numeric"></td>
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
<td><input type="checkbox" name="lan.{{$i}}.internet" {{if $l.Internet}}checked{{end}}></td>
<td><input type="checkbox" name="lan.{{$i}}.dhcp" {{if $l.DHCP}}checked{{end}}></td>
<td><input name="lan.{{$i}}.poolStart" value="{{$l.PoolStart}}" size="13"></td>
<td><input name="lan.{{$i}}.poolEnd" value="{{$l.PoolEnd}}" size="13"></td>
<td>{{if $l.Name}}<input type="checkbox" name="lan.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="10" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (IPv6, reservations, zones) are kept; edit them under Advanced.</p>
</fieldset>
<button>Save</button>
</form>
{{end}}
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "content"}}
{{$s := .Data}}
<section>
<dl>
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
</dl>
</section>
{{if $s.Applying}}
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
{{else if $s.ConfirmRunning}}
<section class="busy"><h2>Confirming…</h2></section>
{{else if gt (seconds $s.RollbackIn) 0}}
<section class="pending">
<h2>Waiting for confirmation</h2>
<p>The new configuration is active. Unless you confirm it within <strong>{{duration $s.RollbackIn}}</strong>,
the router goes back to the confirmed system on its own.</p>
<form method="post" action="/confirm"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Confirm</button></form>
</section>
{{else}}
<section>
{{if $s.Unapplied}}<p>The settings have changes that are not part of the confirmed system.</p>{{else}}<p>The settings match the confirmed system.</p>{{end}}
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
{{if $s.Unapplied}}<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>{{end}}
</div>
</section>
{{end}}
{{if $s.Log}}
<section>
<h2>Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}</h2>
<pre class="log">{{$s.Log}}</pre>
</section>
{{end}}
{{end}}
+61 -1
View File
@@ -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}/")
'';
}