diff --git a/go.mod b/go.mod index aa1c5e0..b934937 100644 --- a/go.mod +++ b/go.mod @@ -40,9 +40,9 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect + golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect - golang.org/x/tools v0.31.0 // indirect + golang.org/x/tools v0.32.0 // indirect google.golang.org/protobuf v1.36.5 // indirect honnef.co/go/tools v0.6.1 // indirect k8s.io/apimachinery v0.32.3 // indirect @@ -52,6 +52,7 @@ require ( tool ( github.com/a-h/templ/cmd/templ + golang.org/x/tools/cmd/goimports golang.org/x/tools/cmd/stringer honnef.co/go/tools/cmd/staticcheck ) diff --git a/go.sum b/go.sum index 5b32f78..316a972 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -128,6 +130,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/internal/slog.go b/internal/slog.go index 115e1d2..456a732 100644 --- a/internal/slog.go +++ b/internal/slog.go @@ -3,6 +3,7 @@ package internal import ( "fmt" "log/slog" + "net/http" "os" ) @@ -22,3 +23,14 @@ func InitSlog(level string) { }) slog.SetDefault(slog.New(h)) } + +func GetRequestLogger(r *http.Request) *slog.Logger { + return slog.With( + "user_agent", r.UserAgent(), + "accept_language", r.Header.Get("Accept-Language"), + "priority", r.Header.Get("Priority"), + "x-forwarded-for", + r.Header.Get("X-Forwarded-For"), + "x-real-ip", r.Header.Get("X-Real-Ip"), + ) +} diff --git a/lib/anubis.go b/lib/anubis.go index 026783e..bc14284 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -2,38 +2,31 @@ package lib import ( "crypto/ed25519" - "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/json" "fmt" - "io" "log/slog" "math" "net" "net/http" "net/url" - "os" "slices" "strconv" "strings" "time" - "github.com/a-h/templ" "github.com/golang-jwt/jwt/v5" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/TecharoHQ/anubis" - "github.com/TecharoHQ/anubis/data" "github.com/TecharoHQ/anubis/decaymap" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dnsbl" "github.com/TecharoHQ/anubis/internal/ogtags" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/config" - "github.com/TecharoHQ/anubis/web" - "github.com/TecharoHQ/anubis/xess" ) var ( @@ -64,121 +57,6 @@ var ( }) ) -type Options struct { - Next http.Handler - Policy *policy.ParsedConfig - RedirectDomains []string - ServeRobotsTXT bool - PrivateKey ed25519.PrivateKey - - CookieDomain string - CookieName string - CookiePartitioned bool - - OGPassthrough bool - OGTimeToLive time.Duration - Target string - - WebmasterEmail string - BasePrefix string -} - -func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) { - var fin io.ReadCloser - var err error - - if fname != "" { - fin, err = os.Open(fname) - if err != nil { - return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err) - } - } else { - fname = "(data)/botPolicies.yaml" - fin, err = data.BotPolicies.Open("botPolicies.yaml") - if err != nil { - return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err) - } - } - - defer func(fin io.ReadCloser) { - err := fin.Close() - if err != nil { - slog.Error("failed to close policy file", "file", fname, "err", err) - } - }(fin) - - anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty) - - return anubisPolicy, err -} - -func New(opts Options) (*Server, error) { - if opts.PrivateKey == nil { - slog.Debug("opts.PrivateKey not set, generating a new one") - _, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return nil, fmt.Errorf("lib: can't generate private key: %v", err) - } - opts.PrivateKey = priv - } - - anubis.BasePrefix = opts.BasePrefix - - result := &Server{ - next: opts.Next, - priv: opts.PrivateKey, - pub: opts.PrivateKey.Public().(ed25519.PublicKey), - policy: opts.Policy, - opts: opts, - DNSBLCache: decaymap.New[string, dnsbl.DroneBLResponse](), - OGTags: ogtags.NewOGTagCache(opts.Target, opts.OGPassthrough, opts.OGTimeToLive), - } - - mux := http.NewServeMux() - xess.Mount(mux) - - // Helper to add global prefix - registerWithPrefix := func(pattern string, handler http.Handler, method string) { - if method != "" { - method = method + " " // methods must end with a space to register with them - } - - // Ensure there's no double slash when concatenating BasePrefix and pattern - basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/") - prefix := method + basePrefix - - // If pattern doesn't start with a slash, add one - if !strings.HasPrefix(pattern, "/") { - pattern = "/" + pattern - } - - mux.Handle(prefix+pattern, handler) - } - - // Ensure there's no double slash when concatenating BasePrefix and StaticPath - stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath - registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "") - - if opts.ServeRobotsTXT { - registerWithPrefix("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.ServeFileFS(w, r, web.Static, "static/robots.txt") - }), "GET") - registerWithPrefix("/.well-known/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.ServeFileFS(w, r, web.Static, "static/robots.txt") - }), "GET") - } - - registerWithPrefix(anubis.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST") - registerWithPrefix(anubis.APIPrefix+"pass-challenge", http.HandlerFunc(result.PassChallenge), "GET") - registerWithPrefix(anubis.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "") - registerWithPrefix(anubis.APIPrefix+"test-error", http.HandlerFunc(result.TestError), "GET") - registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "") - - result.mux = mux - - return result, nil -} - type Server struct { mux *http.ServeMux next http.Handler @@ -190,40 +68,6 @@ type Server struct { OGTags *ogtags.OGTagCache } -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.mux.ServeHTTP(w, r) -} - -func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { - if s.next == nil { - redir := r.FormValue("redir") - urlParsed, err := r.URL.Parse(redir) - if err != nil { - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect URL not parseable", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) - return - } - - if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) { - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) - return - } else if urlParsed.Host != r.URL.Host { - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) - return - } - - if redir != "" { - http.Redirect(w, r, redir, http.StatusFound) - return - } - - templ.Handler( - web.Base("You are not a bot!", web.StaticHappy()), - ).ServeHTTP(w, r) - } else { - s.next.ServeHTTP(w, r) - } -} - func (s *Server) challengeFor(r *http.Request, difficulty int) string { fp := sha256.Sum256(s.priv.Seed()) @@ -248,19 +92,12 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) } func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) { - lg := slog.With( - "user_agent", r.UserAgent(), - "accept_language", r.Header.Get("Accept-Language"), - "priority", r.Header.Get("Priority"), - "x-forwarded-for", - r.Header.Get("X-Forwarded-For"), - "x-real-ip", r.Header.Get("X-Real-Ip"), - ) + lg := internal.GetRequestLogger(r) cr, rule, err := s.check(r) if err != nil { lg.Error("check failed", "err", err) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy\"", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy\"") return } @@ -271,52 +108,11 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS ip := r.Header.Get("X-Real-Ip") - if s.policy.DNSBL && ip != "" { - resp, ok := s.DNSBLCache.Get(ip) - if !ok { - lg.Debug("looking up ip in dnsbl") - resp, err := dnsbl.Lookup(ip) - if err != nil { - lg.Error("can't look up ip in dnsbl", "err", err) - } - s.DNSBLCache.Set(ip, resp, 24*time.Hour) - droneBLHits.WithLabelValues(resp.String()).Inc() - } - - if resp != dnsbl.AllGood { - lg.Info("DNSBL hit", "status", resp.String()) - templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip), s.opts.WebmasterEmail)), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r) - return - } + if s.handleDNSBL(w, r, ip, lg) { + return } - switch cr.Rule { - case config.RuleAllow: - lg.Debug("allowing traffic to origin (explicit)") - s.ServeHTTPNext(w, r) - return - case config.RuleDeny: - s.ClearCookie(w) - lg.Info("explicit deny") - if rule == nil { - lg.Error("rule is nil, cannot calculate checksum") - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) - return - } - hash := rule.Hash() - - lg.Debug("rule hash", "hash", hash) - templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("Access Denied: error code %s", hash), s.opts.WebmasterEmail)), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r) - return - case config.RuleChallenge: - lg.Debug("challenge requested") - case config.RuleBenchmark: - lg.Debug("serving benchmark page") - s.RenderBench(w, r) - return - default: - s.ClearCookie(w) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + if s.checkRules(w, r, cr, lg, rule) { return } @@ -357,53 +153,64 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS s.ServeHTTPNext(w, r) } -func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { - if returnHTTPStatusOnly { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Authorization required")) - return - } - - lg := slog.With( - "user_agent", r.UserAgent(), - "accept_language", r.Header.Get("Accept-Language"), - "priority", r.Header.Get("Priority"), - "x-forwarded-for", - r.Header.Get("X-Forwarded-For"), - "x-real-ip", r.Header.Get("X-Real-Ip"), - ) - - challenge := s.challengeFor(r, rule.Challenge.Difficulty) - - var ogTags map[string]string = nil - if s.opts.OGPassthrough { - var err error - ogTags, err = s.OGTags.GetOGTags(r.URL) - if err != nil { - lg.Error("failed to get OG tags", "err", err) - ogTags = nil +func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool { + switch cr.Rule { + case config.RuleAllow: + lg.Debug("allowing traffic to origin (explicit)") + s.ServeHTTPNext(w, r) + return true + case config.RuleDeny: + s.ClearCookie(w) + lg.Info("explicit deny") + if rule == nil { + lg.Error("rule is nil, cannot calculate checksum") + s.respondWithError(w, r, "Internal Server Error: Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.RuleDeny\"") + return true } - } + hash := rule.Hash() - component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags) - if err != nil { - lg.Error("render failed", "err", err) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) - return + lg.Debug("rule hash", "hash", hash) + s.respondWithStatus(w, r, fmt.Sprintf("Access Denied: error code %s", hash), http.StatusOK) + return true + case config.RuleChallenge: + lg.Debug("challenge requested") + case config.RuleBenchmark: + lg.Debug("serving benchmark page") + s.RenderBench(w, r) + return true + default: + s.ClearCookie(w) + slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule) + s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"") + return true } - - handler := internal.NoStoreCache(templ.Handler(component)) - handler.ServeHTTP(w, r) + return false } -func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { - templ.Handler( - web.Base("Benchmarking Anubis!", web.Bench()), - ).ServeHTTP(w, r) +func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string, lg *slog.Logger) bool { + if s.policy.DNSBL && ip != "" { + resp, ok := s.DNSBLCache.Get(ip) + if !ok { + lg.Debug("looking up ip in dnsbl") + resp, err := dnsbl.Lookup(ip) + if err != nil { + lg.Error("can't look up ip in dnsbl", "err", err) + } + s.DNSBLCache.Set(ip, resp, 24*time.Hour) + droneBLHits.WithLabelValues(resp.String()).Inc() + } + + if resp != dnsbl.AllGood { + lg.Info("DNSBL hit", "status", resp.String()) + s.respondWithStatus(w, r, fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip), http.StatusOK) + return true + } + } + return false } func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { - lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip")) + lg := internal.GetRequestLogger(r) encoder := json.NewEncoder(w) cr, rule, err := s.check(r) @@ -441,19 +248,13 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { } func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { - lg := slog.With( - "user_agent", r.UserAgent(), - "accept_language", r.Header.Get("Accept-Language"), - "priority", r.Header.Get("Priority"), - "x-forwarded-for", r.Header.Get("X-Forwarded-For"), - "x-real-ip", r.Header.Get("X-Real-Ip"), - ) + lg := internal.GetRequestLogger(r) redir := r.FormValue("redir") redirURL, err := url.ParseRequestURI(redir) if err != nil { lg.Error("invalid redirect", "err", err) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid redirect", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "Invalid redirect") return } // used by the path checker rule @@ -462,7 +263,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { cr, rule, err := s.check(r) if err != nil { lg.Error("check failed", "err", err) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".\"") return } lg = lg.With("check_result", cr) @@ -471,7 +272,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if nonceStr == "" { s.ClearCookie(w) lg.Debug("no nonce") - templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing nonce", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "missing nonce") return } @@ -479,7 +280,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if elapsedTimeStr == "" { s.ClearCookie(w) lg.Debug("no elapsedTime") - templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing elapsedTime", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "missing elapsedTime") return } @@ -487,7 +288,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if err != nil { s.ClearCookie(w) lg.Debug("elapsedTime doesn't parse", "err", err) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid elapsedTime", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "invalid elapsedTime") return } @@ -497,15 +298,11 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { response := r.FormValue("response") urlParsed, err := r.URL.Parse(redir) if err != nil { - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect URL not parseable", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "Redirect URL not parseable") return } - - if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) { - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) - return - } else if urlParsed.Host != r.URL.Host { - templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host { + s.respondWithError(w, r, "Redirect domain not allowed") return } @@ -515,7 +312,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if err != nil { s.ClearCookie(w) lg.Debug("nonce doesn't parse", "err", err) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "invalid nonce") return } @@ -525,7 +322,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { s.ClearCookie(w) lg.Debug("hash does not match", "got", response, "want", calculated) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) + s.respondWithStatus(w, r, "invalid response", http.StatusForbidden) failedValidations.Inc() return } @@ -534,7 +331,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) { s.ClearCookie(w) lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) + s.respondWithStatus(w, r, "invalid response", http.StatusForbidden) failedValidations.Inc() return } @@ -557,7 +354,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { if err != nil { lg.Error("failed to sign JWT", "err", err) s.ClearCookie(w) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("failed to sign JWT", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, "failed to sign JWT") return } @@ -578,7 +375,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { func (s *Server) TestError(w http.ResponseWriter, r *http.Request) { err := r.FormValue("err") - templ.Handler(web.Base("Oh noes!", web.ErrorPage(err, s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + s.respondWithError(w, r, err) } func cr(name string, rule config.Rule) policy.CheckResult { diff --git a/lib/config.go b/lib/config.go new file mode 100644 index 0000000..81d2bcd --- /dev/null +++ b/lib/config.go @@ -0,0 +1,138 @@ +package lib + +import ( + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/data" + "github.com/TecharoHQ/anubis/decaymap" + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/internal/dnsbl" + "github.com/TecharoHQ/anubis/internal/ogtags" + "github.com/TecharoHQ/anubis/lib/policy" + "github.com/TecharoHQ/anubis/web" + "github.com/TecharoHQ/anubis/xess" +) + +type Options struct { + Next http.Handler + Policy *policy.ParsedConfig + RedirectDomains []string + ServeRobotsTXT bool + PrivateKey ed25519.PrivateKey + + CookieDomain string + CookieName string + CookiePartitioned bool + + OGPassthrough bool + OGTimeToLive time.Duration + Target string + + WebmasterEmail string + BasePrefix string +} + +func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) { + var fin io.ReadCloser + var err error + + if fname != "" { + fin, err = os.Open(fname) + if err != nil { + return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err) + } + } else { + fname = "(data)/botPolicies.yaml" + fin, err = data.BotPolicies.Open("botPolicies.yaml") + if err != nil { + return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err) + } + } + + defer func(fin io.ReadCloser) { + err := fin.Close() + if err != nil { + slog.Error("failed to close policy file", "file", fname, "err", err) + } + }(fin) + + anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty) + + return anubisPolicy, err +} + +func New(opts Options) (*Server, error) { + if opts.PrivateKey == nil { + slog.Debug("opts.PrivateKey not set, generating a new one") + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("lib: can't generate private key: %v", err) + } + opts.PrivateKey = priv + } + + anubis.BasePrefix = opts.BasePrefix + + result := &Server{ + next: opts.Next, + priv: opts.PrivateKey, + pub: opts.PrivateKey.Public().(ed25519.PublicKey), + policy: opts.Policy, + opts: opts, + DNSBLCache: decaymap.New[string, dnsbl.DroneBLResponse](), + OGTags: ogtags.NewOGTagCache(opts.Target, opts.OGPassthrough, opts.OGTimeToLive), + } + + mux := http.NewServeMux() + xess.Mount(mux) + + // Helper to add global prefix + registerWithPrefix := func(pattern string, handler http.Handler, method string) { + if method != "" { + method = method + " " // methods must end with a space to register with them + } + + // Ensure there's no double slash when concatenating BasePrefix and pattern + basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + prefix := method + basePrefix + + // If pattern doesn't start with a slash, add one + if !strings.HasPrefix(pattern, "/") { + pattern = "/" + pattern + } + + mux.Handle(prefix+pattern, handler) + } + + // Ensure there's no double slash when concatenating BasePrefix and StaticPath + stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath + registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "") + + if opts.ServeRobotsTXT { + registerWithPrefix("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, web.Static, "static/robots.txt") + }), "GET") + registerWithPrefix("/.well-known/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, web.Static, "static/robots.txt") + }), "GET") + } + + registerWithPrefix(anubis.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST") + registerWithPrefix(anubis.APIPrefix+"pass-challenge", http.HandlerFunc(result.PassChallenge), "GET") + registerWithPrefix(anubis.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "") + registerWithPrefix(anubis.APIPrefix+"test-error", http.HandlerFunc(result.TestError), "GET") + registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "") + + result.mux = mux + + return result, nil +} diff --git a/lib/http.go b/lib/http.go index 2f32b6d..9e134b3 100644 --- a/lib/http.go +++ b/lib/http.go @@ -2,8 +2,14 @@ package lib import ( "net/http" + "slices" "time" + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/policy" + "github.com/TecharoHQ/anubis/web" + "github.com/a-h/templ" + "github.com/TecharoHQ/anubis" ) @@ -33,3 +39,79 @@ func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion return t.Transport.RoundTrip(req) } + +func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { + if returnHTTPStatusOnly { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Authorization required")) + return + } + + lg := internal.GetRequestLogger(r) + + challenge := s.challengeFor(r, rule.Challenge.Difficulty) + + var ogTags map[string]string = nil + if s.opts.OGPassthrough { + var err error + ogTags, err = s.OGTags.GetOGTags(r.URL) + if err != nil { + lg.Error("failed to get OG tags", "err", err) + } + } + + component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags) + if err != nil { + lg.Error("render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this. + s.respondWithError(w, r, "Internal Server Error: please contact the administrator and ask them to look for the logs around \"RenderIndex\"") + return + } + + handler := internal.NoStoreCache(templ.Handler(component)) + handler.ServeHTTP(w, r) +} + +func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { + templ.Handler( + web.Base("Benchmarking Anubis!", web.Bench()), + ).ServeHTTP(w, r) +} + +func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message string) { + s.respondWithStatus(w, r, message, http.StatusInternalServerError) +} + +func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg string, status int) { + templ.Handler(web.Base("Oh noes!", web.ErrorPage(msg, s.opts.WebmasterEmail)), templ.WithStatus(status)).ServeHTTP(w, r) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mux.ServeHTTP(w, r) +} + +func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) { + if s.next == nil { + redir := r.FormValue("redir") + urlParsed, err := r.URL.Parse(redir) + if err != nil { + s.respondWithStatus(w, r, "Redirect URL not parseable", http.StatusBadRequest) + return + } + + if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host { + s.respondWithStatus(w, r, "Redirect domain not allowed", http.StatusBadRequest) + return + } + + if redir != "" { + http.Redirect(w, r, redir, http.StatusFound) + return + } + + templ.Handler( + web.Base("You are not a bot!", web.StaticHappy()), + ).ServeHTTP(w, r) + } else { + s.next.ServeHTTP(w, r) + } +}