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:
+262
@@ -0,0 +1,262 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errBadCredentials = errors.New("invalid password")
|
||||
errTooManyAttempts = errors.New("too many failed attempts, try again later")
|
||||
errInternal = errors.New("internal error")
|
||||
)
|
||||
|
||||
// Rule is the JSON representation of a single ruleset rule. The YAML tags
|
||||
// mirror the rule file format, so the same struct can be written back out.
|
||||
type Rule struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Action string `json:"action,omitempty" yaml:"action,omitempty"`
|
||||
Log bool `json:"log,omitempty" yaml:"log,omitempty"`
|
||||
Modifier *RuleModifier `json:"modifier,omitempty" yaml:"modifier,omitempty"`
|
||||
Expr string `json:"expr" yaml:"expr"`
|
||||
}
|
||||
|
||||
// RuleModifier is the modifier attached to a `modify` rule.
|
||||
type RuleModifier struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Args map[string]interface{} `json:"args,omitempty" yaml:"args,omitempty"`
|
||||
}
|
||||
|
||||
// RuleManager gives the web server access to the running ruleset. The
|
||||
// implementation lives in the cmd package, which owns both the rule file and
|
||||
// the engine.
|
||||
type RuleManager interface {
|
||||
// Path returns the path of the rule file.
|
||||
Path() string
|
||||
// Load reads the rule file and returns its raw content and parsed rules.
|
||||
Load() (string, []Rule, error)
|
||||
// Validate parses and compiles the given YAML without applying it.
|
||||
Validate(raw string) ([]Rule, error)
|
||||
// Marshal serializes structured rules back to YAML.
|
||||
Marshal(rules []Rule) (string, error)
|
||||
// Apply validates the given YAML, writes it to the rule file and hot
|
||||
// reloads the engine.
|
||||
Apply(raw string) ([]Rule, error)
|
||||
}
|
||||
|
||||
// Info is the static information about the running instance shown by the UI.
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
Hostname string `json:"hostname"`
|
||||
RuleFile string `json:"ruleFile"`
|
||||
Config ConfigDigest `json:"config"`
|
||||
}
|
||||
|
||||
// ConfigDigest is a read-only summary of the engine configuration.
|
||||
type ConfigDigest struct {
|
||||
IOQueueSize uint32 `json:"ioQueueSize"`
|
||||
IOLocal bool `json:"ioLocal"`
|
||||
IORST bool `json:"ioRST"`
|
||||
Workers int `json:"workers"`
|
||||
WorkerQueue int `json:"workerQueueSize"`
|
||||
UDPMaxStreams int `json:"udpMaxStreams"`
|
||||
GeoIP string `json:"geoip,omitempty"`
|
||||
GeoSite string `json:"geosite,omitempty"`
|
||||
}
|
||||
|
||||
// MetaInfo describes what the engine is capable of. The rule editor uses it
|
||||
// for autocompletion and validation hints.
|
||||
type MetaInfo struct {
|
||||
Analyzers []AnalyzerInfo `json:"analyzers"`
|
||||
Modifiers []string `json:"modifiers"`
|
||||
Actions []string `json:"actions"`
|
||||
Functions []string `json:"functions"`
|
||||
}
|
||||
|
||||
// AnalyzerInfo describes a single analyzer.
|
||||
type AnalyzerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Proto string `json:"proto"`
|
||||
}
|
||||
|
||||
// Config is the web server configuration.
|
||||
type Config struct {
|
||||
// Listen is the address to listen on, e.g. ":8080".
|
||||
Listen string
|
||||
// Secret is the password required to log in. Must not be empty.
|
||||
Secret string
|
||||
// CertFile / KeyFile enable HTTPS when both are set.
|
||||
CertFile string
|
||||
KeyFile string
|
||||
|
||||
Hub *Hub
|
||||
Rules RuleManager
|
||||
Meta MetaInfo
|
||||
Info func() Info
|
||||
|
||||
// Logf is used for the few messages the server produces. Optional.
|
||||
Logf func(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// Server serves the web UI and its API.
|
||||
type Server struct {
|
||||
config Config
|
||||
auth *authenticator
|
||||
mux *http.ServeMux
|
||||
static http.Handler
|
||||
}
|
||||
|
||||
// NewServer creates a new web UI server.
|
||||
func NewServer(config Config) (*Server, error) {
|
||||
if config.Hub == nil {
|
||||
return nil, errors.New("web: hub is required")
|
||||
}
|
||||
if config.Secret == "" {
|
||||
return nil, errors.New("web: secret is required")
|
||||
}
|
||||
if config.Listen == "" {
|
||||
config.Listen = ":8080"
|
||||
}
|
||||
if config.Logf == nil {
|
||||
config.Logf = func(string, ...interface{}) {}
|
||||
}
|
||||
s := &Server{
|
||||
config: config,
|
||||
auth: newAuthenticator(config.Secret),
|
||||
mux: http.NewServeMux(),
|
||||
static: staticHandler(),
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Secret returns the password in use, which is useful when it was generated.
|
||||
func (s *Server) Secret() string { return s.config.Secret }
|
||||
|
||||
// Addr returns the address the server listens on.
|
||||
func (s *Server) Addr() string { return s.config.Listen }
|
||||
|
||||
// TLS reports whether the server serves HTTPS.
|
||||
func (s *Server) TLS() bool { return s.config.CertFile != "" && s.config.KeyFile != "" }
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/v1/login", s.handleLogin)
|
||||
s.mux.HandleFunc("/api/v1/logout", s.guard(s.handleLogout, true))
|
||||
s.mux.HandleFunc("/api/v1/info", s.guard(s.handleInfo, false))
|
||||
s.mux.HandleFunc("/api/v1/meta", s.guard(s.handleMeta, false))
|
||||
s.mux.HandleFunc("/api/v1/metrics", s.guard(s.handleMetrics, false))
|
||||
s.mux.HandleFunc("/api/v1/events", s.guard(s.handleEvents, false))
|
||||
s.mux.HandleFunc("/api/v1/live", s.guard(s.handleLive, false))
|
||||
s.mux.HandleFunc("/api/v1/rules", s.guard(s.handleRules, false))
|
||||
s.mux.HandleFunc("/api/v1/rules/validate", s.guard(s.handleRulesValidate, true))
|
||||
s.mux.HandleFunc("/", s.handleStatic)
|
||||
}
|
||||
|
||||
// Run starts the server and blocks until the context is cancelled.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
srv := &http.Server{
|
||||
Handler: s.securityHeaders(s.mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
}
|
||||
ln, err := net.Listen("tcp", s.config.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
if s.TLS() {
|
||||
errChan <- srv.ServeTLS(ln, s.config.CertFile, s.config.KeyFile)
|
||||
} else {
|
||||
errChan <- srv.Serve(ln)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// guard wraps a handler with authentication. When mutating is true, a bearer
|
||||
// token is required (a session cookie alone is not enough), which makes the
|
||||
// endpoint immune to cross-site request forgery.
|
||||
func (s *Server) guard(next http.HandlerFunc, mutating bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" && !mutating && r.Method == http.MethodGet {
|
||||
token = cookieToken(r)
|
||||
}
|
||||
if !s.auth.valid(token) {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
s.static.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded single page application, falling back to
|
||||
// index.html so that client side routing works on a hard refresh.
|
||||
func staticHandler() http.Handler {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "web UI is not built", http.StatusNotImplemented)
|
||||
})
|
||||
}
|
||||
files := http.FileServer(http.FS(sub))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if name == "" || name == "." {
|
||||
name = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(sub, name); err != nil {
|
||||
// Unknown path: let the SPA router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
r.URL.Path = "/"
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user