Files
OpenGFW/cmd/web.go
T

321 lines
8.4 KiB
Go
Raw Normal View History

2026-07-27 08:09:11 +08:00
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/ruleset/builtins/geo"
2026-07-27 08:09:11 +08:00
"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) },
Geo: func() web.GeoData { return webGeoData(config) },
2026-07-27 08:09:11 +08:00
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,
},
}
}
// webGeoData lists what the configured geo databases contain, so that the rule
// builder can offer a picker instead of a free text field. Loading the files is
// expensive, but the web server only calls this on demand and caches the result.
func webGeoData(config *cliConfig) web.GeoData {
matcher := geo.NewGeoMatcher(config.Ruleset.GeoSite, config.Ruleset.GeoIp)
var data web.GeoData
if entries, err := matcher.ListGeoIP(); err != nil {
data.IPError = err.Error()
} else {
for _, e := range entries {
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
}
}
if entries, err := matcher.ListGeoSite(); err != nil {
data.SiteError = err.Error()
} else {
for _, e := range entries {
data.Site = append(data.Site, web.GeoEntry{
Code: e.Code,
Count: e.Domains,
Attributes: e.Attributes,
})
}
}
return data
}
2026-07-27 08:09:11 +08:00
// 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
}