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"
|
} } accept # wireguard"
|
||||||
}
|
}
|
||||||
${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"}
|
${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 {
|
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 {
|
dns.domain = mkOption {
|
||||||
type = types.nullOr types.str;
|
type = types.nullOr types.str;
|
||||||
default = null;
|
default = null;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ let
|
|||||||
current=$(readlink -f /run/current-system)
|
current=$(readlink -f /run/current-system)
|
||||||
${config.nix.package}/bin/nix-env -p ${profile} --set "$current"
|
${config.nix.package}/bin/nix-env -p ${profile} --set "$current"
|
||||||
"$current/bin/switch-to-configuration" boot
|
"$current/bin/switch-to-configuration" boot
|
||||||
|
${cfg.onConfirm}
|
||||||
echo "router: confirmed $current"
|
echo "router: confirmed $current"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -64,6 +65,12 @@ in
|
|||||||
default = true;
|
default = true;
|
||||||
description = "Install the commit-confirm helpers `router-arm`, `router-confirm` and `router-rollback`.";
|
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 {
|
timeout = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "5min";
|
default = "5min";
|
||||||
|
|||||||
@@ -26,9 +26,13 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.routerSettings;
|
cfg = config.routerSettings;
|
||||||
|
profile = "/nix/var/nix/profiles/system";
|
||||||
settings = lib.importJSON cfg.file;
|
settings = lib.importJSON cfg.file;
|
||||||
system = settings.system or { };
|
system = settings.system or { };
|
||||||
|
|
||||||
|
pending = "/run/router-pending-settings.json";
|
||||||
|
confirmed = "${cfg.stateDirectory}/settings.confirmed.json";
|
||||||
|
|
||||||
apply = pkgs.writeShellApplication {
|
apply = pkgs.writeShellApplication {
|
||||||
name = "router-apply";
|
name = "router-apply";
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
@@ -37,18 +41,29 @@ let
|
|||||||
];
|
];
|
||||||
text = ''
|
text = ''
|
||||||
timeout=''${1:-${config.router.rollback.timeout}}
|
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}"
|
echo "router: building ${cfg.directory}"
|
||||||
new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \
|
new=$(nix build --no-link --print-out-paths --out-link /run/router-pending \
|
||||||
"path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel")
|
"path:${cfg.directory}#nixosConfigurations.router.config.system.build.toplevel")
|
||||||
if [ "$new" = "$(readlink -f /run/current-system)" ]; then
|
if [ "$new" = "$(readlink -f /run/current-system)" ]; then
|
||||||
echo "router: no changes"
|
echo "router: no changes"
|
||||||
|
if [ "$new" = "$(readlink -f ${profile})" ]; then
|
||||||
|
install -m 0600 "$building" ${confirmed}
|
||||||
|
fi
|
||||||
|
rm -f "$building"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
mv "$building" ${pending}
|
||||||
router-arm "$timeout"
|
router-arm "$timeout"
|
||||||
echo "router: activating $new"
|
echo "router: activating $new"
|
||||||
"$new/bin/switch-to-configuration" test
|
"$new/bin/switch-to-configuration" test
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
webui = pkgs.callPackage ../../pkgs/router-webui/package.nix { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Outside `router`: the settings file defines that whole tree.
|
# Outside `router`: the settings file defines that whole tree.
|
||||||
@@ -62,12 +77,34 @@ in
|
|||||||
default = "/var/lib/router/config";
|
default = "/var/lib/router/config";
|
||||||
description = "Where the configuration (flake and settings) lives on the device.";
|
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 = {
|
config = {
|
||||||
router = settings.router // {
|
router = lib.mkMerge [
|
||||||
enable = true;
|
(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";
|
networking.hostName = system.hostName or "router";
|
||||||
time.timeZone = system.timeZone or "UTC";
|
time.timeZone = system.timeZone or "UTC";
|
||||||
@@ -79,7 +116,64 @@ in
|
|||||||
"flakes"
|
"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
|
# Rebuilding on the device must work without internet (the uplink may be
|
||||||
# what is being configured). What a changed configuration rebuilds is all
|
# 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
|
# without network, then
|
||||||
# - rebuilding the unchanged settings gives exactly the installed system,
|
# - rebuilding the unchanged settings gives exactly the installed system,
|
||||||
# - a changed setting is built offline, activated and confirmed,
|
# - 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 }:
|
{ self, pkgs }:
|
||||||
let
|
let
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
@@ -75,6 +77,19 @@ pkgs.testers.runNixOSTest {
|
|||||||
nix.settings.substituters = lib.mkForce [ ];
|
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 = {
|
target = {
|
||||||
imports = [ sharedDisk ];
|
imports = [ sharedDisk ];
|
||||||
# Evaluating a NixOS system takes memory.
|
# Evaluating a NixOS system takes memory.
|
||||||
@@ -136,5 +151,50 @@ pkgs.testers.runNixOSTest {
|
|||||||
assert pool_end() == "192.168.10.120"
|
assert pool_end() == "192.168.10.120"
|
||||||
target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {confirmed}", timeout=120)
|
target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {confirmed}", timeout=120)
|
||||||
assert pool_end() == "192.168.10.150"
|
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