263 lines
8.0 KiB
Go
263 lines
8.0 KiB
Go
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)
|
||
|
|
})
|
||
|
|
}
|