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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module router-webui
|
||||
|
||||
go 1.27
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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}/")
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user