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:
+70
-18
@@ -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),
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/apernet/OpenGFW/analyzer"
|
||||
"github.com/apernet/OpenGFW/engine"
|
||||
"github.com/apernet/OpenGFW/modifier"
|
||||
"github.com/apernet/OpenGFW/ruleset"
|
||||
"github.com/apernet/OpenGFW/web"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// hub is the (optional) statistics collector feeding the web UI. It is nil
|
||||
// when the web UI is disabled.
|
||||
var hub *web.Hub
|
||||
|
||||
// ruleManager owns the rule file and knows how to hot reload the engine.
|
||||
// It is shared between the SIGHUP handler and the web UI.
|
||||
type ruleManager struct {
|
||||
path string
|
||||
analyzers []analyzer.Analyzer
|
||||
modifiers []modifier.Modifier
|
||||
rsConfig *ruleset.BuiltinConfig
|
||||
|
||||
mu sync.Mutex
|
||||
engine engine.Engine
|
||||
}
|
||||
|
||||
var _ web.RuleManager = (*ruleManager)(nil)
|
||||
|
||||
func newRuleManager(path string, ans []analyzer.Analyzer, mods []modifier.Modifier, rsConfig *ruleset.BuiltinConfig) *ruleManager {
|
||||
return &ruleManager{path: path, analyzers: ans, modifiers: mods, rsConfig: rsConfig}
|
||||
}
|
||||
|
||||
func (m *ruleManager) SetEngine(en engine.Engine) {
|
||||
m.mu.Lock()
|
||||
m.engine = en
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *ruleManager) Path() string { return m.path }
|
||||
|
||||
// Compile parses and compiles a rule file content, without applying it.
|
||||
func (m *ruleManager) Compile(raw string) (ruleset.Ruleset, []ruleset.ExprRule, error) {
|
||||
rawRs, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse rules: %w", err)
|
||||
}
|
||||
rs, err := ruleset.CompileExprRules(rawRs, m.analyzers, m.modifiers, m.rsConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compile rules: %w", err)
|
||||
}
|
||||
return rs, rawRs, nil
|
||||
}
|
||||
|
||||
// Reload re-reads the rule file from disk and applies it to the engine.
|
||||
func (m *ruleManager) Reload() error {
|
||||
bs, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rs, _, err := m.Compile(string(bs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.update(rs)
|
||||
}
|
||||
|
||||
func (m *ruleManager) update(rs ruleset.Ruleset) error {
|
||||
m.mu.Lock()
|
||||
en := m.engine
|
||||
m.mu.Unlock()
|
||||
if en == nil {
|
||||
return errors.New("engine is not running")
|
||||
}
|
||||
return en.UpdateRuleset(rs)
|
||||
}
|
||||
|
||||
func (m *ruleManager) Load() (string, []web.Rule, error) {
|
||||
bs, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
rules, err := ruleset.ExprRulesFromYAMLBytes(bs)
|
||||
if err != nil {
|
||||
// The file is still shown as-is so that the user can fix it in the editor.
|
||||
return string(bs), nil, nil
|
||||
}
|
||||
return string(bs), toWebRules(rules), nil
|
||||
}
|
||||
|
||||
func (m *ruleManager) Validate(raw string) ([]web.Rule, error) {
|
||||
_, rules, err := m.Compile(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWebRules(rules), nil
|
||||
}
|
||||
|
||||
func (m *ruleManager) Marshal(rules []web.Rule) (string, error) {
|
||||
out := make([]web.Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.Modifier != nil && r.Modifier.Name == "" {
|
||||
r.Modifier = nil // Leftover from switching a rule away from `modify`
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
bs, err := yaml.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
|
||||
// Apply compiles the given rules, persists them to the rule file and hot
|
||||
// reloads the engine. The file is only written once the rules compile.
|
||||
func (m *ruleManager) Apply(raw string) ([]web.Rule, error) {
|
||||
rs, rules, err := m.Compile(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.writeFile(raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to write rule file: %w", err)
|
||||
}
|
||||
if err := m.update(rs); err != nil {
|
||||
return nil, fmt.Errorf("failed to update ruleset: %w", err)
|
||||
}
|
||||
return toWebRules(rules), nil
|
||||
}
|
||||
|
||||
// writeFile replaces the rule file atomically so that a crash in the middle of
|
||||
// a save cannot leave a truncated ruleset behind.
|
||||
func (m *ruleManager) writeFile(raw string) error {
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(m.path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
}
|
||||
dir := filepath.Dir(m.path)
|
||||
tmp, err := os.CreateTemp(dir, ".rules-*.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // No-op once the rename succeeded
|
||||
if _, err := tmp.WriteString(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, m.path)
|
||||
}
|
||||
|
||||
func toWebRules(rules []ruleset.ExprRule) []web.Rule {
|
||||
out := make([]web.Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
wr := web.Rule{Name: r.Name, Action: r.Action, Log: r.Log, Expr: r.Expr}
|
||||
if r.Modifier.Name != "" {
|
||||
wr.Modifier = &web.RuleModifier{Name: r.Modifier.Name, Args: r.Modifier.Args}
|
||||
}
|
||||
out = append(out, wr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startWebServer starts the web UI. It returns nil when the UI is disabled.
|
||||
func startWebServer(ctx context.Context, config *cliConfig, rm *ruleManager) error {
|
||||
secret := config.Web.Secret
|
||||
generated := false
|
||||
if secret == "" {
|
||||
secret = web.RandomSecret()
|
||||
generated = true
|
||||
}
|
||||
srv, err := web.NewServer(web.Config{
|
||||
Listen: config.Web.Listen,
|
||||
Secret: secret,
|
||||
CertFile: config.Web.Cert,
|
||||
KeyFile: config.Web.Key,
|
||||
Hub: hub,
|
||||
Rules: rm,
|
||||
Meta: webMeta(),
|
||||
Info: func() web.Info { return webInfo(config, rm) },
|
||||
Logf: func(format string, args ...interface{}) {
|
||||
logger.Info(fmt.Sprintf(format, args...))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return configError{Field: "web", Err: err}
|
||||
}
|
||||
scheme := "http"
|
||||
if srv.TLS() {
|
||||
scheme = "https"
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("listen", srv.Addr()),
|
||||
zap.String("scheme", scheme),
|
||||
}
|
||||
if generated {
|
||||
fields = append(fields, zap.String("password", secret))
|
||||
logger.Warn("web UI password was not set, using a generated one", fields...)
|
||||
} else {
|
||||
logger.Info("web UI started", fields...)
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("web UI stopped", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func webMeta() web.MetaInfo {
|
||||
meta := web.MetaInfo{
|
||||
Actions: []string{"allow", "block", "drop", "modify"},
|
||||
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
|
||||
}
|
||||
for _, a := range analyzers {
|
||||
proto := "tcp"
|
||||
if _, ok := a.(analyzer.UDPAnalyzer); ok {
|
||||
proto = "udp"
|
||||
}
|
||||
meta.Analyzers = append(meta.Analyzers, web.AnalyzerInfo{Name: a.Name(), Proto: proto})
|
||||
}
|
||||
for _, m := range modifiers {
|
||||
meta.Modifiers = append(meta.Modifiers, m.Name())
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func webInfo(config *cliConfig, rm *ruleManager) web.Info {
|
||||
hostname, _ := os.Hostname()
|
||||
return web.Info{
|
||||
Version: appVersion,
|
||||
Commit: appCommit,
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
Hostname: hostname,
|
||||
RuleFile: rm.Path(),
|
||||
Config: web.ConfigDigest{
|
||||
IOQueueSize: config.IO.QueueSize,
|
||||
IOLocal: config.IO.Local,
|
||||
IORST: config.IO.RST,
|
||||
Workers: config.Workers.Count,
|
||||
WorkerQueue: config.Workers.QueueSize,
|
||||
UDPMaxStreams: config.Workers.UDPMaxStreams,
|
||||
GeoIP: config.Ruleset.GeoIp,
|
||||
GeoSite: config.Ruleset.GeoSite,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// hubInfo converts engine stream info into the shape the hub understands.
|
||||
func hubInfo(info ruleset.StreamInfo) web.StreamInfo {
|
||||
return web.StreamInfo{
|
||||
ID: info.ID,
|
||||
Proto: info.Protocol.String(),
|
||||
SrcIP: info.SrcIP.String(),
|
||||
SrcPort: info.SrcPort,
|
||||
DstIP: info.DstIP.String(),
|
||||
DstPort: info.DstPort,
|
||||
Props: toWebProps(info.Props),
|
||||
}
|
||||
}
|
||||
|
||||
func toWebProps(props analyzer.CombinedPropMap) web.Props {
|
||||
if len(props) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(web.Props, len(props))
|
||||
for name, p := range props {
|
||||
out[name] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user