81 lines
2.3 KiB
Go
81 lines
2.3 KiB
Go
package edgeguard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type HTTPServer struct {
|
|
guard *Guard
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewHTTPServer(g *Guard, logger *slog.Logger) *HTTPServer {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &HTTPServer{guard: g, logger: logger}
|
|
}
|
|
|
|
func (s *HTTPServer) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", s.healthz)
|
|
mux.HandleFunc("GET /metrics", s.metrics)
|
|
mux.HandleFunc("GET /check", s.check)
|
|
return mux
|
|
}
|
|
|
|
func (s *HTTPServer) healthz(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *HTTPServer) metrics(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write([]byte(s.guard.Metrics()))
|
|
}
|
|
|
|
func (s *HTTPServer) check(w http.ResponseWriter, r *http.Request) {
|
|
rawIP := strings.TrimSpace(r.Header.Get("X-Edge-Client-IP"))
|
|
ip, err := netip.ParseAddr(rawIP)
|
|
if err != nil {
|
|
s.writeDeny(w, http.StatusBadRequest, "invalid client ip", 0)
|
|
return
|
|
}
|
|
host := r.Header.Get("X-Edge-Original-Host")
|
|
method := r.Header.Get("X-Edge-Original-Method")
|
|
uri := r.Header.Get("X-Edge-Original-URI")
|
|
decision := s.guard.Check(ip.Unmap(), host, method, uri, time.Now())
|
|
if decision.Allowed {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
// Do not log every denial here: during an attack that would turn logging
|
|
// itself into a resource-exhaustion vector. Caddy access logs are sampled
|
|
// and EdgeGuard exposes counters; only state changes such as auto-bans are
|
|
// emitted as runtime log events.
|
|
s.writeDeny(w, decision.StatusCode, decision.Reason, decision.RetryAfter)
|
|
}
|
|
|
|
func (s *HTTPServer) writeDeny(w http.ResponseWriter, code int, reason string, retryAfter int) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if retryAfter > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
|
}
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"error": http.StatusText(code),
|
|
"reason": reason,
|
|
})
|
|
}
|