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:
mei
2026-07-27 08:09:11 +08:00
co-authored by Claude Opus 5
parent 393c29bd2d
commit 5a7722d1d2
94 changed files with 8534 additions and 23 deletions
+70 -18
View File
@@ -16,6 +16,7 @@ import (
"github.com/apernet/OpenGFW/modifier"
modUDP "github.com/apernet/OpenGFW/modifier/udp"
"github.com/apernet/OpenGFW/ruleset"
"github.com/apernet/OpenGFW/web"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -36,6 +37,12 @@ const (
appLogFormatEnv = "OPENGFW_LOG_FORMAT"
)
// Build information, set with -ldflags at build time.
var (
appVersion = "dev"
appCommit = ""
)
var logger *zap.Logger
// Flags
@@ -165,6 +172,7 @@ type cliConfig struct {
IO cliConfigIO `mapstructure:"io"`
Workers cliConfigWorkers `mapstructure:"workers"`
Ruleset cliConfigRuleset `mapstructure:"ruleset"`
Web cliConfigWeb `mapstructure:"web"`
}
type cliConfigIO struct {
@@ -188,6 +196,14 @@ type cliConfigRuleset struct {
GeoSite string `mapstructure:"geosite"`
}
type cliConfigWeb struct {
Enabled bool `mapstructure:"enabled"`
Listen string `mapstructure:"listen"`
Secret string `mapstructure:"secret"`
Cert string `mapstructure:"cert"`
Key string `mapstructure:"key"`
}
func (c *cliConfig) fillLogger(config *engine.Config) error {
config.Logger = &engineLogger{}
return nil
@@ -249,18 +265,24 @@ func runMain(cmd *cobra.Command, args []string) {
}
defer engineConfig.IO.Close() // Make sure to close IO on exit
// Ruleset
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
if err != nil {
logger.Fatal("failed to load rules", zap.Error(err))
// Statistics hub for the web UI
if config.Web.Enabled {
hub = web.NewHub()
}
// Ruleset
rsConfig := &ruleset.BuiltinConfig{
Logger: &rulesetLogger{},
GeoSiteFilename: config.Ruleset.GeoSite,
GeoIpFilename: config.Ruleset.GeoIp,
ProtectedDialContext: engineConfig.IO.ProtectedDialContext,
}
rs, err := ruleset.CompileExprRules(rawRs, analyzers, modifiers, rsConfig)
rm := newRuleManager(args[0], analyzers, modifiers, rsConfig)
rawRules, err := os.ReadFile(args[0])
if err != nil {
logger.Fatal("failed to load rules", zap.Error(err))
}
rs, _, err := rm.Compile(string(rawRules))
if err != nil {
logger.Fatal("failed to compile rules", zap.Error(err))
}
@@ -271,6 +293,7 @@ func runMain(cmd *cobra.Command, args []string) {
if err != nil {
logger.Fatal("failed to initialize engine", zap.Error(err))
}
rm.SetEngine(en)
// Signal handling
ctx, cancelFunc := context.WithCancel(context.Background())
@@ -289,25 +312,21 @@ func runMain(cmd *cobra.Command, args []string) {
for {
<-reloadChan
logger.Info("reloading rules")
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
if err != nil {
logger.Error("failed to load rules, using old rules", zap.Error(err))
continue
}
rs, err := ruleset.CompileExprRules(rawRs, analyzers, modifiers, rsConfig)
if err != nil {
logger.Error("failed to compile rules, using old rules", zap.Error(err))
continue
}
err = en.UpdateRuleset(rs)
if err != nil {
logger.Error("failed to update ruleset", zap.Error(err))
if err := rm.Reload(); err != nil {
logger.Error("failed to reload rules, using old rules", zap.Error(err))
} else {
logger.Info("rules reloaded")
}
}
}()
// Web UI
if config.Web.Enabled {
if err := startWebServer(ctx, &config, rm); err != nil {
logger.Fatal("failed to start web UI", zap.Error(err))
}
}
logger.Info("engine started")
logger.Info("engine exited", zap.Error(en.Run(ctx)))
}
@@ -315,14 +334,23 @@ func runMain(cmd *cobra.Command, args []string) {
type engineLogger struct{}
func (l *engineLogger) WorkerStart(id int) {
if hub != nil {
hub.WorkerStarted()
}
logger.Debug("worker started", zap.Int("id", id))
}
func (l *engineLogger) WorkerStop(id int) {
if hub != nil {
hub.WorkerStopped()
}
logger.Debug("worker stopped", zap.Int("id", id))
}
func (l *engineLogger) TCPStreamNew(workerID int, info ruleset.StreamInfo) {
if hub != nil {
hub.StreamNew("tcp")
}
logger.Debug("new TCP stream",
zap.Int("workerID", workerID),
zap.Int64("id", info.ID),
@@ -331,6 +359,9 @@ func (l *engineLogger) TCPStreamNew(workerID int, info ruleset.StreamInfo) {
}
func (l *engineLogger) TCPStreamPropUpdate(info ruleset.StreamInfo, close bool) {
if hub != nil {
hub.PropUpdate(toWebProps(info.Props))
}
logger.Debug("TCP stream property update",
zap.Int64("id", info.ID),
zap.String("src", info.SrcString()),
@@ -340,6 +371,9 @@ func (l *engineLogger) TCPStreamPropUpdate(info ruleset.StreamInfo, close bool)
}
func (l *engineLogger) TCPStreamAction(info ruleset.StreamInfo, action ruleset.Action, noMatch bool) {
if hub != nil {
hub.StreamAction(hubInfo(info), action.String())
}
logger.Info("TCP stream action",
zap.Int64("id", info.ID),
zap.String("src", info.SrcString()),
@@ -349,6 +383,9 @@ func (l *engineLogger) TCPStreamAction(info ruleset.StreamInfo, action ruleset.A
}
func (l *engineLogger) UDPStreamNew(workerID int, info ruleset.StreamInfo) {
if hub != nil {
hub.StreamNew("udp")
}
logger.Debug("new UDP stream",
zap.Int("workerID", workerID),
zap.Int64("id", info.ID),
@@ -357,6 +394,9 @@ func (l *engineLogger) UDPStreamNew(workerID int, info ruleset.StreamInfo) {
}
func (l *engineLogger) UDPStreamPropUpdate(info ruleset.StreamInfo, close bool) {
if hub != nil {
hub.PropUpdate(toWebProps(info.Props))
}
logger.Debug("UDP stream property update",
zap.Int64("id", info.ID),
zap.String("src", info.SrcString()),
@@ -366,6 +406,9 @@ func (l *engineLogger) UDPStreamPropUpdate(info ruleset.StreamInfo, close bool)
}
func (l *engineLogger) UDPStreamAction(info ruleset.StreamInfo, action ruleset.Action, noMatch bool) {
if hub != nil {
hub.StreamAction(hubInfo(info), action.String())
}
logger.Info("UDP stream action",
zap.Int64("id", info.ID),
zap.String("src", info.SrcString()),
@@ -375,6 +418,9 @@ func (l *engineLogger) UDPStreamAction(info ruleset.StreamInfo, action ruleset.A
}
func (l *engineLogger) ModifyError(info ruleset.StreamInfo, err error) {
if hub != nil {
hub.Error(hubInfo(info), "", err.Error())
}
logger.Error("modify error",
zap.Int64("id", info.ID),
zap.String("src", info.SrcString()),
@@ -406,6 +452,9 @@ func (l *engineLogger) AnalyzerErrorf(streamID int64, name string, format string
type rulesetLogger struct{}
func (l *rulesetLogger) Log(info ruleset.StreamInfo, name string) {
if hub != nil {
hub.RuleLog(hubInfo(info), name)
}
logger.Info("ruleset log",
zap.String("name", name),
zap.Int64("id", info.ID),
@@ -415,6 +464,9 @@ func (l *rulesetLogger) Log(info ruleset.StreamInfo, name string) {
}
func (l *rulesetLogger) MatchError(info ruleset.StreamInfo, name string, err error) {
if hub != nil {
hub.Error(hubInfo(info), name, err.Error())
}
logger.Error("ruleset match error",
zap.String("name", name),
zap.Int64("id", info.ID),