feat: add embedded web UI
Adds an optional web dashboard served by OpenGFW itself, enabled with a new `web` section in the config file. Backend (web package, decoupled from engine/io so it builds on any OS): - hub.go collects statistics off the engine logger callbacks: atomic counters, two ring-buffered time series (10s and 1min buckets), top N hosts/blocked destinations/rules/analyzers, and a 512 entry event buffer fanned out to connected clients over SSE. Slow clients drop frames instead of blocking the engine. - api.go exposes /api/v1 for info, meta, metrics, events, the SSE stream and ruleset read/validate/replace. - auth.go implements password login with in-memory session tokens and login rate limiting. Mutating endpoints require the bearer token (the session cookie is only accepted for GET), which makes them CSRF-safe. - cmd/web.go implements the rule manager: rules are compiled before anything is written, the file is replaced atomically and the engine is hot reloaded. The SIGHUP handler now shares that same path. - web/devserver serves the UI with synthetic traffic for frontend work on machines where the engine itself cannot be built. Frontend (web/frontend, Vue 3 + Vite + Tailwind CSS v4 + Reka UI): dashboard, live event feed with analyzer property inspection, visual and YAML rule editors, analyzer overview and settings. Responsive down to phone sizes with a bottom tab bar and bottom-sheet dialogs, plus light/dark themes and English/Chinese translations. The built UI in web/dist is committed and embedded with go:embed so that `go build` works without Node; CI builds the frontend and checks that the committed output is up to date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+152
@@ -0,0 +1,152 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "opengfw_session"
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
maxLoginFailures = 8
|
||||
loginBanDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
type authenticator struct {
|
||||
secret string
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time // token -> expiry
|
||||
failures map[string]*failureRecord
|
||||
}
|
||||
|
||||
type failureRecord struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func newAuthenticator(secret string) *authenticator {
|
||||
return &authenticator{
|
||||
secret: secret,
|
||||
sessions: make(map[string]time.Time),
|
||||
failures: make(map[string]*failureRecord),
|
||||
}
|
||||
}
|
||||
|
||||
// RandomSecret generates a secret to be used when the user did not set one.
|
||||
func RandomSecret() string {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "opengfw"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// login verifies the password and returns a new session token.
|
||||
func (a *authenticator) login(remoteAddr, password string) (string, time.Time, error) {
|
||||
ip := hostOnly(remoteAddr)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if rec, ok := a.failures[ip]; ok && rec.count >= maxLoginFailures && now.Before(rec.until) {
|
||||
return "", time.Time{}, errTooManyAttempts
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(a.secret)) != 1 {
|
||||
rec, ok := a.failures[ip]
|
||||
if !ok || now.After(rec.until) {
|
||||
rec = &failureRecord{}
|
||||
a.failures[ip] = rec
|
||||
}
|
||||
rec.count++
|
||||
rec.until = now.Add(loginBanDuration)
|
||||
return "", time.Time{}, errBadCredentials
|
||||
}
|
||||
delete(a.failures, ip)
|
||||
token := newToken()
|
||||
if token == "" {
|
||||
return "", time.Time{}, errInternal
|
||||
}
|
||||
expiry := now.Add(sessionTTL)
|
||||
a.sessions[token] = expiry
|
||||
a.gcLocked(now)
|
||||
return token, expiry, nil
|
||||
}
|
||||
|
||||
func (a *authenticator) logout(token string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *authenticator) valid(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
expiry, ok := a.sessions[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expiry) {
|
||||
delete(a.sessions, token)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *authenticator) gcLocked(now time.Time) {
|
||||
for t, exp := range a.sessions {
|
||||
if now.After(exp) {
|
||||
delete(a.sessions, t)
|
||||
}
|
||||
}
|
||||
for ip, rec := range a.failures {
|
||||
if now.After(rec.until) {
|
||||
delete(a.failures, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken returns the token carried by the Authorization header, if any.
|
||||
func bearerToken(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
|
||||
return h[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cookieToken returns the token carried by the session cookie, if any.
|
||||
func cookieToken(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user