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