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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user