Files
OpenGFW/web/server.go
T
meiandClaude Opus 5 5a7722d1d2 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>
2026-07-27 08:09:11 +08:00

263 lines
8.0 KiB
Go

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)
})
}