aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJason Cameron <git@jasoncameron.dev>2025-04-27 09:36:39 -0400
committerGitHub <noreply@github.com>2025-04-27 13:36:39 +0000
commit301c7a42bde10cad814f9caa9f6320356734f499 (patch)
tree5aaf8d8a79bc77662cf3abf6ad9a97e9f4bb5502
parent755c18a9a76cf07e71f4c8e5f6cbc890411cf38f (diff)
downloadanubis-301c7a42bde10cad814f9caa9f6320356734f499.tar.xz
anubis-301c7a42bde10cad814f9caa9f6320356734f499.zip
refactor(lib): Split up anubis.go into some smaller files. (#379)
* refactor(logging): centralize logger creation in GetLogger function Signed-off-by: Jason Cameron <git@jasoncameron.dev> * refactor(logging): rename GetLogger to GetRequestLogger for clarity Signed-off-by: Jason Cameron <git@jasoncameron.dev> * refactor: streamline error handling and response methods Signed-off-by: Jason Cameron <git@jasoncameron.dev> * refactor(lib): Split anubis.go up into some smaller specialized methods Signed-off-by: Jason Cameron <git@jasoncameron.dev> * refactor(http): simplify error response handling by using respondWithStatus Signed-off-by: Jason Cameron <git@jasoncameron.dev> * chore(lib): run goimports Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Jason Cameron <git@jasoncameron.dev> Signed-off-by: Xe Iaso <me@xeiaso.net> Co-authored-by: Xe Iaso <me@xeiaso.net>
-rw-r--r--go.mod5
-rw-r--r--go.sum4
-rw-r--r--internal/slog.go12
-rw-r--r--lib/anubis.go341
-rw-r--r--lib/config.go138
-rw-r--r--lib/http.go82
6 files changed, 308 insertions, 274 deletions
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
- }
- }
-
- switch cr.Rule {
- case config.RuleAllow:
- lg.Debug("allowing traffic to origin (explicit)")
- s.ServeHTTPNext(w, r)
+ if s.handleDNSBL(w, r, ip, lg) {
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)
+ }
+}