diff --git a/src/webui/Makefile b/src/webui/Makefile index e21f50dd..bcb6e3e2 100644 --- a/src/webui/Makefile +++ b/src/webui/Makefile @@ -7,7 +7,7 @@ build: go build -ldflags="-s -w" -o $(BINARY) . dev: build - go run . --listen :10000 --session-key /tmp/webui-session.key --insecure-tls $(ARGS) + go run . --listen :10000 --insecure-tls $(ARGS) clean: rm -f $(BINARY) diff --git a/src/webui/README.md b/src/webui/README.md index d847f94b..61cb2ff0 100644 --- a/src/webui/README.md +++ b/src/webui/README.md @@ -50,7 +50,6 @@ GOOS=linux GOARCH=arm64 make build |-------------------|-----------------------------------|-------------------------------------------| | `--listen` | `:10000` | Address to listen on | | `--restconf` | `http://localhost:8080/restconf` | RESTCONF base URL of the device | -| `--session-key` | `/var/lib/misc/webui-session.key` | Path to persistent session encryption key | | `--insecure-tls` | `false` | Disable TLS certificate verification | The RESTCONF URL can also be set via the `RESTCONF_URL` environment diff --git a/src/webui/internal/auth/store.go b/src/webui/internal/auth/store.go index 25051ce9..c150c1c8 100644 --- a/src/webui/internal/auth/store.go +++ b/src/webui/internal/auth/store.go @@ -3,154 +3,128 @@ package auth import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "encoding/json" "fmt" - "io" - "os" - "path/filepath" + "sync" "time" + + "github.com/kernelkit/webui/internal/security" ) const sessionTimeout = 1 * time.Hour -type tokenPayload struct { - Username string `json:"u"` - Password string `json:"p"` - CsrfToken string `json:"c"` - CreatedAt int64 `json:"t"` - Features map[string]bool `json:"f,omitempty"` +// sessionEntry is the in-memory state of a single authenticated +// session. The user's password is kept alongside the token so the +// webui can re-authenticate to RESTCONF on the user's behalf on each +// request; it is never written to disk or surfaced over the wire. +type sessionEntry struct { + username string + password string + csrfToken string + features map[string]bool + lastSeenAt time.Time } -// SessionStore issues and validates stateless encrypted tokens. -// The cookie value is a base64url-encoded AES-256-GCM sealed blob -// containing the user's credentials and a creation timestamp. -// No server-side session map is needed — only the AES key must -// persist across restarts. +// SessionStore issues opaque random session tokens backed by an +// in-memory map. Nothing is persisted: a webui restart drops every +// active session and the UI's 401 handler surfaces a fresh login +// page. type SessionStore struct { - aead cipher.AEAD + mu sync.RWMutex + sessions map[string]*sessionEntry } -// NewSessionStore creates a store. If keyFile is non-empty, the AES -// key is read from that path (or generated and written there on first -// run). If keyFile is empty, a random ephemeral key is used. -func NewSessionStore(keyFile string) (*SessionStore, error) { - key, err := loadOrCreateKey(keyFile) - if err != nil { - return nil, err +// NewSessionStore returns a fresh store and starts a janitor +// goroutine that sweeps expired entries once a minute. +func NewSessionStore() *SessionStore { + s := &SessionStore{ + sessions: make(map[string]*sessionEntry), } - - block, err := aes.NewCipher(key[:]) - if err != nil { - return nil, err - } - aead, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - return &SessionStore{aead: aead}, nil + go s.janitor() + return s } -// Create returns an encrypted token carrying the user's credentials and capabilities. +// Create issues a session for the given credentials, returning the +// session token (cookie value) and a freshly minted CSRF token. func (s *SessionStore) Create(username, password string, features map[string]bool) (string, string, error) { - csrf := randomToken() - token, err := s.CreateWithCSRF(username, password, csrf, features) - return token, csrf, err -} - -// CreateWithCSRF returns an encrypted token carrying the user's credentials, -// capabilities, and a bound CSRF token. -func (s *SessionStore) CreateWithCSRF(username, password, csrf string, features map[string]bool) (string, error) { - payload, err := json.Marshal(tokenPayload{ - Username: username, - Password: password, - CsrfToken: csrf, - CreatedAt: time.Now().Unix(), - Features: features, - }) + token, err := security.RandomToken() if err != nil { - return "", err + return "", "", fmt.Errorf("session token: %w", err) + } + csrf, err := security.RandomToken() + if err != nil { + return "", "", fmt.Errorf("csrf token: %w", err) } - nonce := make([]byte, s.aead.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err + s.mu.Lock() + s.sessions[token] = &sessionEntry{ + username: username, + password: password, + csrfToken: csrf, + features: features, + lastSeenAt: time.Now(), } - - sealed := s.aead.Seal(nonce, nonce, payload, nil) - return base64.RawURLEncoding.EncodeToString(sealed), nil + s.mu.Unlock() + return token, csrf, nil } -// Lookup decrypts a token and returns the credentials and capabilities if valid. +// Lookup returns the credentials associated with a session token, or +// ok=false if the token is unknown or expired. An expired entry is +// reaped as a side effect. func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) { - raw, err := base64.RawURLEncoding.DecodeString(token) - if err != nil { - return "", "", "", nil, false + s.mu.RLock() + e, present := s.sessions[token] + if present && time.Since(e.lastSeenAt) <= sessionTimeout { + username, password, csrf, features = e.username, e.password, e.csrfToken, e.features + s.mu.RUnlock() + return username, password, csrf, features, true } + s.mu.RUnlock() - ns := s.aead.NonceSize() - if len(raw) < ns { - return "", "", "", nil, false + // Slow path: the entry was either missing or expired at read + // time. Re-check under the write lock — Refresh may have + // updated lastSeenAt in the gap — and only delete if still + // expired. + if present { + s.mu.Lock() + if e, ok := s.sessions[token]; ok && time.Since(e.lastSeenAt) > sessionTimeout { + delete(s.sessions, token) + } + s.mu.Unlock() } - - plaintext, err := s.aead.Open(nil, raw[:ns], raw[ns:], nil) - if err != nil { - return "", "", "", nil, false - } - - var p tokenPayload - if err := json.Unmarshal(plaintext, &p); err != nil { - return "", "", "", nil, false - } - - if time.Since(time.Unix(p.CreatedAt, 0)) > sessionTimeout { - return "", "", "", nil, false - } - - return p.Username, p.Password, p.CsrfToken, p.Features, true + return "", "", "", nil, false } -// Delete is a no-op for stateless tokens (the cookie is cleared by -// the caller), but kept to satisfy the existing logout flow. -func (s *SessionStore) Delete(token string) {} - -// loadOrCreateKey returns a 32-byte AES key. When path is non-empty -// the key is persisted so sessions survive restarts. -func loadOrCreateKey(path string) ([32]byte, error) { - var key [32]byte - - if path != "" { - data, err := os.ReadFile(path) - if err == nil && len(data) == 32 { - copy(key[:], data) - return key, nil - } +// Refresh extends a session's lifetime by resetting its last-seen +// timestamp. Called by the auth middleware on each user-driven +// request so an active session doesn't expire mid-use. No-op if the +// token is unknown. +func (s *SessionStore) Refresh(token string) { + s.mu.Lock() + if e, ok := s.sessions[token]; ok { + e.lastSeenAt = time.Now() } - - if _, err := io.ReadFull(rand.Reader, key[:]); err != nil { - return key, fmt.Errorf("generate session key: %w", err) - } - - if path != "" { - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return key, fmt.Errorf("create key directory: %w", err) - } - if err := os.WriteFile(path, key[:], 0600); err != nil { - return key, fmt.Errorf("write session key: %w", err) - } - } - - return key, nil + s.mu.Unlock() } -func randomToken() string { - var b [32]byte - if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { - return "" +// Delete revokes a session. Called by logout and by Lookup on +// expiration. +func (s *SessionStore) Delete(token string) { + s.mu.Lock() + delete(s.sessions, token) + s.mu.Unlock() +} + +func (s *SessionStore) janitor() { + t := time.NewTicker(1 * time.Minute) + defer t.Stop() + for range t.C { + cutoff := time.Now().Add(-sessionTimeout) + s.mu.Lock() + for token, e := range s.sessions { + if e.lastSeenAt.Before(cutoff) { + delete(s.sessions, token) + } + } + s.mu.Unlock() } - return base64.RawURLEncoding.EncodeToString(b[:]) } diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 74018bc5..cf00b8d9 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -4,12 +4,15 @@ package handlers import ( "context" + "net/http" + "github.com/kernelkit/webui/internal/restconf" "github.com/kernelkit/webui/internal/security" ) // PageData is the base template data passed to every page. type PageData struct { + Username string CsrfToken string PageTitle string ActivePage string @@ -19,3 +22,13 @@ type PageData struct { func csrfToken(ctx context.Context) string { return security.TokenFromContext(ctx) } + +func newPageData(r *http.Request, page, title string) PageData { + return PageData{ + Username: restconf.CredentialsFromContext(r.Context()).Username, + CsrfToken: csrfToken(r.Context()), + PageTitle: title, + ActivePage: page, + Capabilities: CapabilitiesFromContext(r.Context()), + } +} diff --git a/src/webui/internal/handlers/containers.go b/src/webui/internal/handlers/containers.go index 19807383..887bb3d7 100644 --- a/src/webui/internal/handlers/containers.go +++ b/src/webui/internal/handlers/containers.go @@ -67,12 +67,9 @@ type ContainerEntry struct { // containersData is the template data for the containers page. type containersData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Containers []ContainerEntry - Error string + PageData + Containers []ContainerEntry + Error string } // ContainersHandler serves the containers status page. @@ -84,10 +81,7 @@ type ContainersHandler struct { // Overview renders the containers list page. func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) { data := containersData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "Containers", - ActivePage: "containers", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "containers", "Containers"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index de0f2248..b63ea7ff 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -177,12 +177,8 @@ type hwComponentJSON struct { // Template data structures. type dashboardData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities - Hostname string + PageData + Hostname string OSName string OSVersion string Machine string @@ -232,13 +228,8 @@ type DashboardHandler struct { // Index renders the dashboard (GET /). func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := dashboardData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "dashboard", - PageTitle: "Dashboard", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "dashboard", "Dashboard"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/dhcp.go b/src/webui/internal/handlers/dhcp.go index 4c81731d..966c1273 100644 --- a/src/webui/internal/handlers/dhcp.go +++ b/src/webui/internal/handlers/dhcp.go @@ -44,12 +44,9 @@ type DHCPData struct { // ─── Page data ─────────────────────────────────────────────────────────────── type dhcpPageData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - DHCP *DHCPData - Error string + PageData + DHCP *DHCPData + Error string } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -63,10 +60,7 @@ type DHCPHandler struct { // Overview renders the DHCP page (GET /dhcp). func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := dhcpPageData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "DHCP Server", - ActivePage: "dhcp", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "dhcp", "DHCP Server"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go index 64c48582..dcf03765 100644 --- a/src/webui/internal/handlers/firewall.go +++ b/src/webui/internal/handlers/firewall.go @@ -58,12 +58,8 @@ type policyJSON struct { // Template data structures. type firewallData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities - Enabled bool + PageData + Enabled bool EnabledText string DefaultZone string Lockdown bool @@ -111,13 +107,8 @@ type FirewallHandler struct { // Overview renders the firewall overview (GET /firewall). func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := firewallData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "firewall", - PageTitle: "Firewall", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "firewall", "Firewall"), } var fw firewallWrapper diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 58687bc5..0bc28180 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -184,13 +184,9 @@ type ethFrameStats struct { // Template data structures. type interfacesData struct { - CsrfToken string - Username string - ActivePage string - Capabilities *Capabilities - PageTitle string - Interfaces []ifaceEntry - Error string + PageData + Interfaces []ifaceEntry + Error string } type ifaceEntry struct { @@ -221,13 +217,8 @@ type InterfacesHandler struct { // Overview renders the interfaces page (GET /interfaces). func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := interfacesData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "interfaces", - PageTitle: "Interfaces", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "interfaces", "Interfaces"), } var ifaces interfacesWrapper @@ -389,12 +380,8 @@ func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry { // Template data for the interface detail page. type ifaceDetailData struct { - CsrfToken string - Username string - ActivePage string - Capabilities *Capabilities - PageTitle string - Name string + PageData + Name string Type string Status string StatusUp bool @@ -486,11 +473,9 @@ func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*iface } // buildDetailData converts raw RESTCONF interface data to template data. -func buildDetailData(username, csrf string, iface *ifaceJSON) ifaceDetailData { +func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { d := ifaceDetailData{ - Username: username, - CsrfToken: csrf, - Name: iface.Name, + Name: iface.Name, Type: prettyIfType(iface.Type), Status: iface.OperStatus, StatusUp: iface.OperStatus == "up", @@ -727,7 +712,6 @@ func formatCount(n int64) string { // Detail renders the interface detail page (GET /interfaces/{name}). func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - creds := restconf.CredentialsFromContext(r.Context()) iface, err := h.fetchInterface(r, name) if err != nil { @@ -736,10 +720,8 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { return } - data := buildDetailData(creds.Username, csrfToken(r.Context()), iface) - data.ActivePage = "interfaces" - data.PageTitle = "Interface " + name - data.Capabilities = CapabilitiesFromContext(r.Context()) + data := buildDetailData(r, iface) + data.PageData = newPageData(r, "interfaces", "Interface "+name) tmplName := "iface-detail.html" if r.Header.Get("HX-Request") == "true" { @@ -754,7 +736,6 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { // Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters). func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - creds := restconf.CredentialsFromContext(r.Context()) iface, err := h.fetchInterface(r, name) if err != nil { @@ -763,7 +744,7 @@ func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) { return } - data := buildDetailData(creds.Username, csrfToken(r.Context()), iface) + data := buildDetailData(r, iface) if err := h.CountersTemplate.ExecuteTemplate(w, "iface-counters", data); err != nil { log.Printf("template error: %v", err) diff --git a/src/webui/internal/handlers/keystore.go b/src/webui/internal/handlers/keystore.go index f76f0631..098c332e 100644 --- a/src/webui/internal/handlers/keystore.go +++ b/src/webui/internal/handlers/keystore.go @@ -58,11 +58,7 @@ type certificateJSON struct { // Template data structures. type keystoreData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities + PageData SymmetricKeys []symKeyEntry AsymmetricKeys []asymKeyEntry Empty bool @@ -93,13 +89,8 @@ type KeystoreHandler struct { // Overview renders the keystore overview (GET /keystore). func (h *KeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := keystoreData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "keystore", - PageTitle: "Keystore", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "keystore", "Keystore"), } var ks keystoreWrapper diff --git a/src/webui/internal/handlers/lldp.go b/src/webui/internal/handlers/lldp.go index c3d9e19b..33b19c05 100644 --- a/src/webui/internal/handlers/lldp.go +++ b/src/webui/internal/handlers/lldp.go @@ -30,12 +30,9 @@ type LLDPNeighbor struct { // ─── Page data ─────────────────────────────────────────────────────────────── type lldpPageData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Neighbors []LLDPNeighbor - Error string + PageData + Neighbors []LLDPNeighbor + Error string } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -49,10 +46,7 @@ type LLDPHandler struct { // Overview renders the LLDP page (GET /lldp). func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := lldpPageData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "LLDP Neighbors", - ActivePage: "lldp", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "lldp", "LLDP Neighbors"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/ntp.go b/src/webui/internal/handlers/ntp.go index 8e4a40a1..0b456ffa 100644 --- a/src/webui/internal/handlers/ntp.go +++ b/src/webui/internal/handlers/ntp.go @@ -40,12 +40,9 @@ type NTPData struct { // ─── Page data ─────────────────────────────────────────────────────────────── type ntpPageData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - NTP *NTPData - Error string + PageData + NTP *NTPData + Error string } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -59,10 +56,7 @@ type NTPHandler struct { // Overview renders the NTP page (GET /ntp). func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := ntpPageData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "NTP", - ActivePage: "ntp", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "ntp", "NTP"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go index 69a983e0..c8464cc7 100644 --- a/src/webui/internal/handlers/routing.go +++ b/src/webui/internal/handlers/routing.go @@ -41,10 +41,7 @@ type OSPFIface struct { } type routingData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities + PageData Routes []RouteEntry OSPFNeighbors []OSPFNeighbor OSPFIfaces []OSPFIface @@ -180,10 +177,7 @@ type ospfNeighborJSON struct { func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { data := routingData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "Routing", - ActivePage: "routing", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "routing", "Routing"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 3f9664d9..a8ad9289 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -106,12 +106,8 @@ type fwSlotBundle struct { // Template data for the firmware page. type firmwareData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities - Slots []slotEntry + PageData + Slots []slotEntry Installer *installerEntry Error string Message string @@ -135,14 +131,9 @@ type installerEntry struct { // Firmware renders the firmware overview page (GET /firmware). func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := firmwareData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "firmware", - PageTitle: "Firmware", - Capabilities: CapabilitiesFromContext(r.Context()), - Message: r.URL.Query().Get("msg"), + PageData: newPageData(r, "firmware", "Firmware"), + Message: r.URL.Query().Get("msg"), } var sw fwSoftwareWrapper diff --git a/src/webui/internal/handlers/vpn.go b/src/webui/internal/handlers/vpn.go index 6def10e9..9edbc9e7 100644 --- a/src/webui/internal/handlers/vpn.go +++ b/src/webui/internal/handlers/vpn.go @@ -47,12 +47,9 @@ type WGTunnel struct { // vpnData is the template data struct for the VPN page. type vpnData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Tunnels []WGTunnel - Error string + PageData + Tunnels []WGTunnel + Error string } // VPNHandler serves the VPN/WireGuard status page. @@ -64,10 +61,7 @@ type VPNHandler struct { // Overview renders the VPN page (GET /vpn). func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) { data := vpnData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "VPN", - ActivePage: "vpn", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "vpn", "VPN"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index 89c80cf9..4a59c066 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -137,12 +137,9 @@ type WiFiScan struct { // wifiData is the top-level template data for the WiFi page. type wifiData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Radios []WiFiRadio - Error string + PageData + Radios []WiFiRadio + Error string } // WiFiHandler serves the WiFi status page. @@ -154,10 +151,7 @@ type WiFiHandler struct { // Overview renders the WiFi page (GET /wifi). func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) { data := wifiData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "WiFi", - ActivePage: "wifi", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "wifi", "WiFi"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/security/csrf.go b/src/webui/internal/security/csrf.go index 91a0232a..a022192d 100644 --- a/src/webui/internal/security/csrf.go +++ b/src/webui/internal/security/csrf.go @@ -6,6 +6,8 @@ import ( "context" "crypto/rand" "encoding/base64" + "fmt" + "io" "net/http" "strings" ) @@ -35,7 +37,10 @@ func EnsureToken(w http.ResponseWriter, r *http.Request, preferred string) strin } } - token := randomToken() + token, err := RandomToken() + if err != nil { + return "" + } http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: token, @@ -60,12 +65,14 @@ func TokenFromContext(ctx context.Context) string { return "" } -func randomToken() string { +// RandomToken returns 32 bytes of crypto/rand, base64-url-encoded. +// Used for both CSRF tokens and session-store entries. +func RandomToken() (string, error) { var b [32]byte - if _, err := rand.Read(b[:]); err != nil { - return "" + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return "", fmt.Errorf("read random: %w", err) } - return base64.RawURLEncoding.EncodeToString(b[:]) + return base64.RawURLEncoding.EncodeToString(b[:]), nil } func validToken(token string) bool { diff --git a/src/webui/internal/server/middleware.go b/src/webui/internal/server/middleware.go index 31dff8e4..be3b4fbd 100644 --- a/src/webui/internal/server/middleware.go +++ b/src/webui/internal/server/middleware.go @@ -15,10 +15,10 @@ import ( const cookieName = "session" -// authMiddleware checks the session cookie on every request, looks up -// the session, and attaches decrypted credentials to the context. -// Unauthenticated requests are redirected to /login (or get a 401 if -// the request comes from HTMX). +// authMiddleware checks the session cookie on every request, looks +// the token up in the in-memory store, and attaches the session's +// credentials to the context. Unauthenticated requests are +// redirected to /login (or get a 401 if the request comes from HTMX). func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isPublicPath(r.URL.Path) { @@ -38,20 +38,12 @@ func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler { return } - // Sliding window: re-issue the cookie with a fresh timestamp. - // Skip for background polling endpoints so they don't keep - // the session alive indefinitely. + // Sliding window: extend the session on user-driven requests. + // Background polling endpoints are excluded so an idle user + // (just leaving the dashboard open in a tab) still hits the + // timeout. if !isPollingPath(r.URL.Path) { - if fresh, err := store.CreateWithCSRF(username, password, csrf, features); err == nil { - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: fresh, - Path: "/", - HttpOnly: true, - Secure: security.IsSecureRequest(r), - SameSite: http.SameSiteLaxMode, - }) - } + store.Refresh(cookie.Value) } ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{ diff --git a/src/webui/main.go b/src/webui/main.go index 65fc5246..ab0e54bb 100644 --- a/src/webui/main.go +++ b/src/webui/main.go @@ -31,14 +31,10 @@ func main() { listen := flag.String("listen", ":10000", "address to listen on") restconfURL := flag.String("restconf", defaultRC, "RESTCONF base URL") - sessionKey := flag.String("session-key", "/var/lib/misc/webui-session.key", "path to persistent session key file") insecureTLS := flag.Bool("insecure-tls", envBool("INSECURE_TLS"), "disable TLS certificate verification") flag.Parse() - store, err := auth.NewSessionStore(*sessionKey) - if err != nil { - log.Fatalf("session store: %v", err) - } + store := auth.NewSessionStore() rc := restconf.NewClient(*restconfURL, *insecureTLS) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 9f69573d..70416f1a 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -146,7 +146,8 @@ a:hover { .layout { display: flex; - min-height: 100vh; + height: 100vh; + overflow: hidden; } #sidebar { @@ -157,13 +158,20 @@ a:hover { flex-direction: column; flex-shrink: 0; border-right: 1px solid var(--sidebar-border); + overflow-y: auto; +} + +.main-column { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; } #content { flex: 1; padding: 2rem; overflow-y: auto; - height: 100vh; } /* ========================================================================== @@ -1006,31 +1014,26 @@ details[open].nav-group > summary.nav-group-summary::after { .signal-poor { color: var(--danger); font-weight: 600; } /* ========================================================================== - Topbar & Theme Toggle + Topbar & User Menu ========================================================================== */ .topbar { - display: none; - align-items: center; - justify-content: flex-end; - padding: 0.5rem 1rem; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - -.theme-toggle { - background: none; - border: none; - cursor: pointer; - color: var(--sidebar-fg); display: flex; align-items: center; - gap: 0.625rem; - padding: 0; + justify-content: flex-end; + padding: 0 1rem; + height: 48px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + z-index: 100; } -.theme-toggle:hover { - color: #fff; +.topbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; } .hamburger-btn { @@ -1040,7 +1043,7 @@ details[open].nav-group > summary.nav-group-summary::after { padding: 0.375rem; cursor: pointer; color: var(--fg-muted); - display: none; /* Hidden by default; shown via media query */ + display: none; align-items: center; } .hamburger-btn:hover { @@ -1048,6 +1051,137 @@ details[open].nav-group > summary.nav-group-summary::after { border-color: var(--fg-muted); } +/* User menu button */ +.user-menu { + position: relative; +} + +.user-menu-btn { + display: flex; + align-items: center; + gap: 0.4rem; + background: none; + border: none; + border-radius: var(--radius); + padding: 0.35rem 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + transition: color 0.15s; +} +.user-menu-btn:hover { + color: var(--fg); +} +.user-menu-btn .chevron { + color: var(--fg-muted); + transition: transform 0.2s; +} +.user-menu-btn[aria-expanded="true"] .chevron { + transform: rotate(180deg); +} + +/* Dropdown panel — opens on hover */ +.user-dropdown { + position: absolute; + top: 100%; + right: 0; + min-width: 190px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.375rem 0; + z-index: 200; + /* Hidden by default; shown on hover or when button is aria-expanded */ + opacity: 0; + pointer-events: none; + transform: translateY(-4px); + transition: opacity 0.15s, transform 0.15s; +} +.user-menu:hover .user-dropdown, +.user-menu-btn[aria-expanded="true"] + .user-dropdown { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.dropdown-section-label { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-muted); + padding: 0.25rem 0.875rem 0.125rem; +} + +.dropdown-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.375rem 0; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.875rem; + font-size: 0.875rem; + color: var(--fg-muted); + background: none; + border: none; + cursor: pointer; + text-decoration: none; + text-align: left; + transition: background 0.1s, color 0.1s; + box-sizing: border-box; +} +.dropdown-item:hover, +.dropdown-item:focus { + background: var(--border-subtle); + color: var(--fg); + text-decoration: none; +} +.dropdown-item svg { flex-shrink: 0; color: var(--fg-muted); transition: color 0.1s; } +.dropdown-item:hover svg, +.dropdown-item:focus svg { color: var(--fg); } +.dropdown-item-danger:hover, +.dropdown-item-danger:focus { color: var(--danger); } +.dropdown-item-danger:hover svg, +.dropdown-item-danger:focus svg { color: var(--danger); } + +/* Theme checkmark — hidden by default, shown on active option */ +.theme-check { margin-left: auto; flex-shrink: 0; opacity: 0; transition: opacity 0.1s; } +.theme-opt.is-active .theme-check { opacity: 1; } + +/* Login page floating theme button */ +.login-theme-btn { + position: fixed; + bottom: 1.25rem; + right: 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.45rem; + cursor: pointer; + color: var(--fg-muted); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + transition: all 0.15s; +} +.login-theme-btn:hover { color: var(--fg); border-color: var(--fg-muted); } + +/* Login lock icon */ +.login-icon { + display: flex; + justify-content: center; + margin-bottom: 1.5rem; + color: var(--fg-muted); +} + /* ========================================================================== Responsive Layout ========================================================================== */ @@ -1059,7 +1193,7 @@ details[open].nav-group > summary.nav-group-summary::after { top: 0; left: 0; height: 100%; - z-index: 200; + z-index: 300; transform: translateX(-100%); transition: transform 0.25s ease; width: var(--sidebar-width); @@ -1074,22 +1208,17 @@ details[open].nav-group > summary.nav-group-summary::after { position: fixed; inset: 0; background: rgba(0,0,0,0.4); - z-index: 100; + z-index: 200; } #content { height: auto; - min-height: 100vh; + min-height: 100%; } .hamburger-btn { display: flex; } - - .topbar { - display: flex; - justify-content: space-between; - } } /* Tablet (769px–1024px): Icon-only sidebar */ @@ -1115,22 +1244,6 @@ details[open].nav-group > summary.nav-group-summary::after { padding: 0.6rem; } - .sidebar-footer .btn-sidebar-action span:not(.nav-icon) { - display: none; - } - - .sidebar-footer .btn-logout span:not(.nav-icon) { - display: none; - } - - .theme-toggle span { - display: none; - } - - .theme-toggle { - justify-content: center; - } - .sidebar-header { display: flex; align-items: center; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 81119d08..fe69109a 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -135,35 +135,38 @@ } })(); -// Dark mode toggle (auto / light / dark) +// Theme (auto / light / dark) — shared by main app and login page (function() { function getTheme() { var m = document.cookie.split(';').map(function(c){return c.trim();}).find(function(c){return c.indexOf('theme=')===0;}); return m ? m.split('=')[1] : null; } + function applyTheme(mode) { - var el = document.documentElement; - el.classList.remove('dark', 'light'); - if (mode === 'dark') { - el.classList.add('dark'); - } else if (mode === 'light') { - el.classList.add('light'); - } - updateIcon(mode || 'auto'); + document.documentElement.classList.remove('dark', 'light'); + if (mode === 'dark') document.documentElement.classList.add('dark'); + else if (mode === 'light') document.documentElement.classList.add('light'); + updateDropdownCheck(mode || 'auto'); + updateLoginToggleIcon(mode || 'auto'); } - function updateIcon(mode) { - var btn = document.getElementById('theme-toggle'); + + function updateDropdownCheck(mode) { + document.querySelectorAll('.theme-opt').forEach(function(btn) { + btn.classList.toggle('is-active', btn.getAttribute('data-theme') === mode); + }); + } + + function updateLoginToggleIcon(mode) { + // Login page floating toggle — cycle auto→light→dark + var btn = document.getElementById('login-theme-toggle'); if (!btn) return; - var icons = btn.querySelectorAll('svg'); - for (var i = 0; i < icons.length; i++) icons[i].style.display = 'none'; - var id = mode === 'dark' ? 'icon-dark' : mode === 'light' ? 'icon-light' : 'icon-auto'; - var active = btn.querySelector('#' + id); - if (active) active.style.display = ''; - btn.setAttribute('aria-label', - mode === 'dark' ? 'Theme: dark (click for auto)' : - mode === 'light' ? 'Theme: light (click for dark)' : - 'Theme: auto (click for light)'); + var ids = {auto: 'lti-auto', light: 'lti-light', dark: 'lti-dark'}; + Object.keys(ids).forEach(function(k) { + var el = btn.querySelector('#' + ids[k]); + if (el) el.style.display = (k === mode) ? '' : 'none'; + }); } + function setTheme(mode) { if (mode) { document.cookie = 'theme=' + mode + '; path=/; max-age=31536000; samesite=lax'; @@ -172,16 +175,32 @@ } applyTheme(mode); } - var saved = getTheme(); - applyTheme(saved); + + // Apply saved theme immediately (before DOMContentLoaded to avoid flash) + applyTheme(getTheme()); + document.addEventListener('DOMContentLoaded', function() { - var btn = document.getElementById('theme-toggle'); - if (btn) btn.addEventListener('click', function() { - var cur = getTheme(); - if (!cur) setTheme('light'); - else if (cur === 'light') setTheme('dark'); - else setTheme(null); + // Apply checkmarks now that DOM is ready + updateDropdownCheck(getTheme() || 'auto'); + + // Dropdown theme options (main app) + document.querySelectorAll('.theme-opt').forEach(function(btn) { + btn.addEventListener('click', function() { + var t = btn.getAttribute('data-theme'); + setTheme(t === 'auto' ? null : t); + }); }); + + // Login page floating toggle — cycles auto → light → dark → auto + var loginBtn = document.getElementById('login-theme-toggle'); + if (loginBtn) { + loginBtn.addEventListener('click', function() { + var cur = getTheme(); + if (!cur || cur === 'auto') setTheme('light'); + else if (cur === 'light') setTheme('dark'); + else setTheme(null); + }); + } }); })(); @@ -205,6 +224,25 @@ }); })(); +// User menu — hover handled by CSS; JS manages aria-expanded and keyboard +(function() { + document.addEventListener('DOMContentLoaded', function() { + var menu = document.getElementById('user-menu'); + var btn = document.getElementById('user-menu-btn'); + if (!menu || !btn) return; + + menu.addEventListener('mouseenter', function() { + btn.setAttribute('aria-expanded', 'true'); + }); + menu.addEventListener('mouseleave', function() { + btn.setAttribute('aria-expanded', 'false'); + }); + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') btn.setAttribute('aria-expanded', 'false'); + }); + }); +})(); + // Sidebar toggle (mobile) (function() { document.addEventListener('DOMContentLoaded', function() { @@ -230,3 +268,30 @@ }); }); })(); + +// ─── data-autofocus: WM-friendly focus on visible ────────────────────────── +// Focus the [data-autofocus] element only when the page is actually +// visible — calling .focus() on a background tab/desktop is treated +// as an attention hint by Cinnamon/Muffin (and similar X11 WMs) +// and pulls the whole browser window to the foreground. +(function() { + function focusAutofocusWhenVisible() { + var el = document.querySelector('[data-autofocus]'); + if (!el) return; + var apply = function () { + if (document.visibilityState !== 'hidden') el.focus({ preventScroll: true }); + }; + if (document.visibilityState !== 'hidden') { + apply(); + return; + } + var handler = function () { + if (document.visibilityState !== 'hidden') { + document.removeEventListener('visibilitychange', handler); + apply(); + } + }; + document.addEventListener('visibilitychange', handler); + } + document.addEventListener('DOMContentLoaded', focusAutofocusWhenVisible); +})(); diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index f2278db1..18037c2b 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -7,7 +7,7 @@ - {{if .PageTitle}}{{.PageTitle}} — {{end}}Infix + {{if .PageTitle}}{{.PageTitle}} — {{end}}Management @@ -15,21 +15,75 @@ -
-
- -
{{template "sidebar" .}} -
- {{block "content" .}}{{end}} -
+
+
+ +
+
+ +
+ + + + + + + + Download Config + + + +
+ + +
+
+
+
+
+
+ {{block "content" .}}{{end}} +
+
diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 1947ca17..da54e7e7 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -160,38 +160,5 @@ - {{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html index 78d5bb71..ac6530a7 100644 --- a/src/webui/templates/pages/login.html +++ b/src/webui/templates/pages/login.html @@ -6,7 +6,7 @@ - Infix — Login + Login @@ -14,26 +14,52 @@
-
- - {{if .Error}} - - {{end}} -
- -
- - + + {{if .Error}} + + {{end}} +
+ +
+ + {{/* data-autofocus instead of autofocus: the focus is applied + from JS when the page becomes visible, so loading /login + in a background tab / on another desktop doesn't pull + the browser window forward in Linux WMs (Cinnamon / + Muffin treat focus-on-load as an attention hint). */}} + +
+
+ + +
+ +
+
+