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>
153 lines
3.1 KiB
Go
153 lines
3.1 KiB
Go
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
|
|
}
|