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:
@@ -0,0 +1,229 @@
|
||||
// Command devserver runs the OpenGFW web UI against synthetic data.
|
||||
//
|
||||
// The engine itself only builds on Linux (it needs NFQueue), so this little
|
||||
// program exists to let the frontend be developed and reviewed anywhere:
|
||||
//
|
||||
// go run ./web/devserver
|
||||
//
|
||||
// It serves the embedded UI on :8080 with the password "opengfw".
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/apernet/OpenGFW/web"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const password = "opengfw"
|
||||
|
||||
func main() {
|
||||
hub := web.NewHub()
|
||||
rm := &memoryRules{}
|
||||
if err := rm.init(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
srv, err := web.NewServer(web.Config{
|
||||
Listen: ":8080",
|
||||
Secret: password,
|
||||
Hub: hub,
|
||||
Rules: rm,
|
||||
Meta: web.MetaInfo{
|
||||
Analyzers: []web.AnalyzerInfo{
|
||||
{Name: "http", Proto: "tcp"}, {Name: "tls", Proto: "tcp"},
|
||||
{Name: "ssh", Proto: "tcp"}, {Name: "socks", Proto: "tcp"},
|
||||
{Name: "trojan", Proto: "tcp"}, {Name: "fet", Proto: "tcp"},
|
||||
{Name: "dns", Proto: "udp"}, {Name: "quic", Proto: "udp"},
|
||||
{Name: "openvpn", Proto: "udp"}, {Name: "wireguard", Proto: "udp"},
|
||||
},
|
||||
Modifiers: []string{"dns"},
|
||||
Actions: []string{"allow", "block", "drop", "modify"},
|
||||
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
|
||||
},
|
||||
Info: func() web.Info {
|
||||
host, _ := os.Hostname()
|
||||
return web.Info{
|
||||
Version: "devserver",
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
Hostname: host,
|
||||
RuleFile: "rules.yaml (in memory)",
|
||||
Config: web.ConfigDigest{
|
||||
IOQueueSize: 1024, IORST: true, Workers: 4,
|
||||
WorkerQueue: 64, UDPMaxStreams: 4096,
|
||||
GeoIP: "geoip.dat", GeoSite: "geosite.dat",
|
||||
},
|
||||
}
|
||||
},
|
||||
Logf: log.Printf,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||
<-ch
|
||||
cancel()
|
||||
}()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
hub.WorkerStarted()
|
||||
}
|
||||
go generate(ctx, hub)
|
||||
|
||||
log.Printf("web UI on http://127.0.0.1:8080 (password: %s)", password)
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
hosts = []string{
|
||||
"www.google.com", "github.com", "cdn.jsdelivr.net", "telegram.org",
|
||||
"ads.example.net", "tracker.evil.test", "api.openai.com", "www.wikipedia.org",
|
||||
"registry.npmjs.org", "malware.bad.test",
|
||||
}
|
||||
ips = []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "104.16.132.229", "2606:4700::6810:84e5"}
|
||||
)
|
||||
|
||||
// generate feeds the hub with plausible looking traffic.
|
||||
func generate(ctx context.Context, hub *web.Hub) {
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
ticker := time.NewTicker(120 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var id int64
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for n := rng.Intn(6); n >= 0; n-- {
|
||||
id++
|
||||
udp := rng.Intn(3) == 0
|
||||
proto := "tcp"
|
||||
if udp {
|
||||
proto = "udp"
|
||||
}
|
||||
hub.StreamNew(proto)
|
||||
|
||||
host := hosts[rng.Intn(len(hosts))]
|
||||
props := web.Props{}
|
||||
if udp {
|
||||
props["dns"] = web.PropMap{
|
||||
"qr": false,
|
||||
"questions": []map[string]interface{}{{"name": host, "type": 1}},
|
||||
}
|
||||
} else {
|
||||
props["tls"] = web.PropMap{"req": map[string]interface{}{
|
||||
"sni": host, "version": 771,
|
||||
}}
|
||||
}
|
||||
hub.PropUpdate(props)
|
||||
|
||||
info := web.StreamInfo{
|
||||
ID: id,
|
||||
Proto: proto,
|
||||
SrcIP: fmt.Sprintf("192.168.1.%d", 2+rng.Intn(60)),
|
||||
SrcPort: uint16(20000 + rng.Intn(40000)),
|
||||
DstIP: ips[rng.Intn(len(ips))],
|
||||
DstPort: 443,
|
||||
Props: props,
|
||||
}
|
||||
|
||||
switch {
|
||||
case rng.Intn(10) == 0:
|
||||
hub.RuleLog(info, "log-suspicious")
|
||||
hub.StreamAction(info, "block")
|
||||
case rng.Intn(12) == 0:
|
||||
hub.StreamAction(info, "drop")
|
||||
case rng.Intn(14) == 0:
|
||||
hub.StreamAction(info, "modify")
|
||||
case rng.Intn(30) == 0:
|
||||
hub.Error(info, "geoip-rule", "lookup timeout")
|
||||
default:
|
||||
hub.StreamAction(info, "allow")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// memoryRules is an in-memory web.RuleManager: it validates the YAML shape but
|
||||
// does not compile expressions, which is enough for UI work.
|
||||
type memoryRules struct {
|
||||
raw string
|
||||
rules []web.Rule
|
||||
}
|
||||
|
||||
const seedRules = `- name: block-malware
|
||||
action: block
|
||||
log: true
|
||||
expr: 'tls != nil && tls.req != nil && string(tls.req.sni) endsWith ".bad.test"'
|
||||
- name: block-ads-dns
|
||||
action: drop
|
||||
expr: 'dns != nil && any(dns.questions, {.name endsWith "ads.example.net"})'
|
||||
- name: log-ssh
|
||||
log: true
|
||||
expr: 'ssh != nil'
|
||||
`
|
||||
|
||||
func (m *memoryRules) init() error {
|
||||
rules, err := parse(seedRules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.raw, m.rules = seedRules, rules
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryRules) Path() string { return "rules.yaml" }
|
||||
|
||||
func (m *memoryRules) Load() (string, []web.Rule, error) { return m.raw, m.rules, nil }
|
||||
|
||||
func (m *memoryRules) Validate(raw string) ([]web.Rule, error) { return parse(raw) }
|
||||
|
||||
func (m *memoryRules) Marshal(rules []web.Rule) (string, error) {
|
||||
bs, err := yaml.Marshal(rules)
|
||||
return string(bs), err
|
||||
}
|
||||
|
||||
func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
|
||||
rules, err := parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.raw, m.rules = raw, rules
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func parse(raw string) ([]web.Rule, error) {
|
||||
var rules []web.Rule
|
||||
if err := yaml.Unmarshal([]byte(raw), &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, r := range rules {
|
||||
if r.Name == "" {
|
||||
return nil, fmt.Errorf("rule #%d has no name", i+1)
|
||||
}
|
||||
if r.Expr == "" {
|
||||
return nil, fmt.Errorf("rule %q has no expression", r.Name)
|
||||
}
|
||||
if r.Action == "" && !r.Log {
|
||||
return nil, fmt.Errorf("rule %q must have at least one of action or log", r.Name)
|
||||
}
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
Reference in New Issue
Block a user