Add a web UI for settings-configured routers

router-webui (Go, standard library only) edits settings.json: host
name, WAN (DHCP or PPPoE), LANs with DHCP, and the raw JSON for the
rest; keys the forms do not know are kept. It applies and confirms by
starting router-apply.service and router-confirm.service, which a
switch never restarts, and shows the build log and the rollback
countdown. HTTPS with a self-signed certificate, a PBKDF2 password
hash, CSRF tokens and an Origin check; reachable from management LANs
only (new router.managementPorts).

router-apply records the settings it built and router-confirm keeps
them as settings.confirmed.json (new rollback.onConfirm hook), so the
UI can tell unapplied changes and discard them.

checks.apply now also logs in from a LAN client, discards the change
left by the rollback, edits the DHCP pool, applies and confirms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:43:58 +01:00
co-authored by Claude Opus 5.5
parent 2c8d5562f9
commit 753459f5d7
20 changed files with 1685 additions and 6 deletions
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
// Password hashes are stored as "pbkdf2-sha256$<iterations>$<salt>$<key>"
// (base64, no padding) in a file only root can read.
const (
pbkdf2Iterations = 600_000
pbkdf2KeyLen = 32
)
var b64 = base64.RawStdEncoding
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, pbkdf2KeyLen)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iterations, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
var errBadHash = errors.New("unrecognised password hash")
func checkPassword(stored, password string) (bool, error) {
parts := strings.Split(strings.TrimSpace(stored), "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false, errBadHash
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter < 1 {
return false, errBadHash
}
salt, err1 := b64.DecodeString(parts[2])
want, err2 := b64.DecodeString(parts[3])
if err1 != nil || err2 != nil || len(want) == 0 {
return false, errBadHash
}
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
if err != nil {
return false, err
}
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
// Sessions live in memory: a restart of the UI (e.g. by a switch) logs
// everyone out, which is acceptable for a single-admin box.
type session struct {
csrf string
expires time.Time
}
type sessions struct {
mu sync.Mutex
ttl time.Duration
m map[string]session
}
func newSessions(ttl time.Duration) *sessions {
return &sessions{ttl: ttl, m: map[string]session{}}
}
func (s *sessions) create() (id string, sess session) {
s.mu.Lock()
defer s.mu.Unlock()
id = randomToken()
sess = session{csrf: randomToken(), expires: time.Now().Add(s.ttl)}
s.m[id] = sess
return id, sess
}
// get returns the session and extends it.
func (s *sessions) get(id string) (session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.m[id]
if !ok || time.Now().After(sess.expires) {
delete(s.m, id)
return session{}, false
}
sess.expires = time.Now().Add(s.ttl)
s.m[id] = sess
return sess, true
}
func (s *sessions) delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, id)
}