Compare commits

...

7 Commits

Author SHA1 Message Date
Xe Iaso
b0f0913ea2 v1.17.0: Asahi sas Brutus
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-04-27 15:16:25 -04:00
Xe Iaso
5423ab013a ci(packages): final pre-release yeet bump (#384)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-04-27 16:54:03 +00:00
Jason Cameron
301c7a42bd 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>
2025-04-27 13:36:39 +00:00
Kistaro Windrider
755c18a9a7 README: Fix broken link to policy definition docs. (#380) 2025-04-27 13:33:41 +00:00
Xe Iaso
0fa9906e3a test(config): add Xesite's old policy file to known good test cases (#382)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-04-27 13:32:50 +00:00
p0008874
b08580ca33 docs(known-instances): add Codeberg. (#381)
Signed-off-by: p0008874 <75534590+p0008874@users.noreply.github.com>
2025-04-27 12:17:27 +00:00
Xe Iaso
d8f923974e chore: blank commit to unbreak git
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-04-26 13:52:13 -04:00
14 changed files with 396 additions and 281 deletions

View File

@@ -64,7 +64,7 @@ jobs:
- name: Build Packages - name: Build Packages
run: | run: |
wget https://github.com/TecharoHQ/yeet/releases/download/v0.1.1/yeet_0.1.1_amd64.deb -O var/yeet.deb wget https://github.com/TecharoHQ/yeet/releases/download/v0.2.1/yeet_0.2.1_amd64.deb -O var/yeet.deb
sudo apt -y install -f ./var/yeet.deb sudo apt -y install -f ./var/yeet.deb
rm ./var/yeet.deb rm ./var/yeet.deb
yeet yeet

View File

@@ -66,7 +66,7 @@ jobs:
- name: Build Packages - name: Build Packages
run: | run: |
wget https://github.com/TecharoHQ/yeet/releases/download/v0.1.1/yeet_0.1.1_amd64.deb -O var/yeet.deb wget https://github.com/TecharoHQ/yeet/releases/download/v0.2.1/yeet_0.2.1_amd64.deb -O var/yeet.deb
sudo apt -y install -f ./var/yeet.deb sudo apt -y install -f ./var/yeet.deb
rm ./var/yeet.deb rm ./var/yeet.deb
yeet yeet

View File

@@ -22,7 +22,7 @@ Anubis [weighs the soul of your connection](https://en.wikipedia.org/wiki/Weighi
This program is designed to help protect the small internet from the endless storm of requests that flood in from AI companies. Anubis is as lightweight as possible to ensure that everyone can afford to protect the communities closest to them. This program is designed to help protect the small internet from the endless storm of requests that flood in from AI companies. Anubis is as lightweight as possible to ensure that everyone can afford to protect the communities closest to them.
Anubis is a bit of a nuclear response. This will result in your website being blocked from smaller scrapers and may inhibit "good bots" like the Internet Archive. You can configure [bot policy definitions](./admin/policies.mdx) to explicitly allowlist them and we are working on a curated set of "known good" bots to allow for a compromise between discoverability and uptime. Anubis is a bit of a nuclear response. This will result in your website being blocked from smaller scrapers and may inhibit "good bots" like the Internet Archive. You can configure [bot policy definitions](./docs/docs/admin/policies.mdx) to explicitly allowlist them and we are working on a curated set of "known good" bots to allow for a compromise between discoverability and uptime.
In most cases, you should not need this and can probably get by using Cloudflare to protect a given origin. However, for circumstances where you can't or won't use Cloudflare, Anubis is there for you. In most cases, you should not need this and can probably get by using Cloudflare to protect a given origin. However, for circumstances where you can't or won't use Cloudflare, Anubis is there for you.

View File

@@ -1 +1 @@
1.16.0 1.17.0

View File

@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## v1.17.0: Asahi sas Brutus
- Ensure regexes can't end in newlines ([#372](https://github.com/TecharoHQ/anubis/issues/372)) - Ensure regexes can't end in newlines ([#372](https://github.com/TecharoHQ/anubis/issues/372))
- Add documentation for default allow behavior (implicit rule) - Add documentation for default allow behavior (implicit rule)
- Enable [importing configuration snippets](./admin/configuration/import.mdx) ([#321](https://github.com/TecharoHQ/anubis/pull/321)) - Enable [importing configuration snippets](./admin/configuration/import.mdx) ([#321](https://github.com/TecharoHQ/anubis/pull/321))

View File

@@ -29,9 +29,9 @@ This page contains a non-exhaustive list with all websites using Anubis.
- https://wiki.archlinux.org/ - https://wiki.archlinux.org/
- https://git.devuan.org/ - https://git.devuan.org/
- https://hydra.nixos.org/ - https://hydra.nixos.org/
- https://hydra.nixos.org/
- https://codeberg.org/
- <details> - <details>
<summary>The United Nations</summary> <summary>The United Nations</summary>
- https://policytoolbox.iiep.unesco.org/ - https://policytoolbox.iiep.unesco.org/
</details> </details>

5
go.mod
View File

@@ -40,9 +40,9 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.24.0 // 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/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 google.golang.org/protobuf v1.36.5 // indirect
honnef.co/go/tools v0.6.1 // indirect honnef.co/go/tools v0.6.1 // indirect
k8s.io/apimachinery v0.32.3 // indirect k8s.io/apimachinery v0.32.3 // indirect
@@ -52,6 +52,7 @@ require (
tool ( tool (
github.com/a-h/templ/cmd/templ github.com/a-h/templ/cmd/templ
golang.org/x/tools/cmd/goimports
golang.org/x/tools/cmd/stringer golang.org/x/tools/cmd/stringer
honnef.co/go/tools/cmd/staticcheck honnef.co/go/tools/cmd/staticcheck
) )

4
go.sum
View File

@@ -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.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 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 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= 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 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=

View File

@@ -3,6 +3,7 @@ package internal
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
) )
@@ -22,3 +23,14 @@ func InitSlog(level string) {
}) })
slog.SetDefault(slog.New(h)) 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"),
)
}

View File

@@ -2,38 +2,31 @@ package lib
import ( import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"math" "math"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/a-h/templ"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/decaymap" "github.com/TecharoHQ/anubis/decaymap"
"github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/dnsbl" "github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags" "github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config" "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/web"
"github.com/TecharoHQ/anubis/xess"
) )
var ( 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 { type Server struct {
mux *http.ServeMux mux *http.ServeMux
next http.Handler next http.Handler
@@ -190,40 +68,6 @@ type Server struct {
OGTags *ogtags.OGTagCache 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 { func (s *Server) challengeFor(r *http.Request, difficulty int) string {
fp := sha256.Sum256(s.priv.Seed()) 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) { func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
lg := slog.With( lg := internal.GetRequestLogger(r)
"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"),
)
cr, rule, err := s.check(r) cr, rule, err := s.check(r)
if err != nil { if err != nil {
lg.Error("check failed", "err", err) 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 return
} }
@@ -271,52 +108,11 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
ip := r.Header.Get("X-Real-Ip") ip := r.Header.Get("X-Real-Ip")
if s.policy.DNSBL && ip != "" { if s.handleDNSBL(w, r, ip, lg) {
resp, ok := s.DNSBLCache.Get(ip) return
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 { if s.checkRules(w, r, cr, lg, 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)
return return
} }
@@ -357,53 +153,64 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
s.ServeHTTPNext(w, r) s.ServeHTTPNext(w, r)
} }
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {
if returnHTTPStatusOnly { switch cr.Rule {
w.WriteHeader(http.StatusUnauthorized) case config.RuleAllow:
w.Write([]byte("Authorization required")) lg.Debug("allowing traffic to origin (explicit)")
return s.ServeHTTPNext(w, r)
} return true
case config.RuleDeny:
lg := slog.With( s.ClearCookie(w)
"user_agent", r.UserAgent(), lg.Info("explicit deny")
"accept_language", r.Header.Get("Accept-Language"), if rule == nil {
"priority", r.Header.Get("Priority"), lg.Error("rule is nil, cannot calculate checksum")
"x-forwarded-for", s.respondWithError(w, r, "Internal Server Error: Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.RuleDeny\"")
r.Header.Get("X-Forwarded-For"), return true
"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
} }
} hash := rule.Hash()
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags) lg.Debug("rule hash", "hash", hash)
if err != nil { s.respondWithStatus(w, r, fmt.Sprintf("Access Denied: error code %s", hash), http.StatusOK)
lg.Error("render failed", "err", err) return true
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) case config.RuleChallenge:
return 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
} }
return false
handler := internal.NoStoreCache(templ.Handler(component))
handler.ServeHTTP(w, r)
} }
func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string, lg *slog.Logger) bool {
templ.Handler( if s.policy.DNSBL && ip != "" {
web.Base("Benchmarking Anubis!", web.Bench()), resp, ok := s.DNSBLCache.Get(ip)
).ServeHTTP(w, r) 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) { 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) encoder := json.NewEncoder(w)
cr, rule, err := s.check(r) 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) { func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
lg := slog.With( lg := internal.GetRequestLogger(r)
"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"),
)
redir := r.FormValue("redir") redir := r.FormValue("redir")
redirURL, err := url.ParseRequestURI(redir) redirURL, err := url.ParseRequestURI(redir)
if err != nil { if err != nil {
lg.Error("invalid redirect", "err", err) 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 return
} }
// used by the path checker rule // 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) cr, rule, err := s.check(r)
if err != nil { if err != nil {
lg.Error("check failed", "err", err) 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 return
} }
lg = lg.With("check_result", cr) lg = lg.With("check_result", cr)
@@ -471,7 +272,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if nonceStr == "" { if nonceStr == "" {
s.ClearCookie(w) s.ClearCookie(w)
lg.Debug("no nonce") 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 return
} }
@@ -479,7 +280,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if elapsedTimeStr == "" { if elapsedTimeStr == "" {
s.ClearCookie(w) s.ClearCookie(w)
lg.Debug("no elapsedTime") 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 return
} }
@@ -487,7 +288,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
s.ClearCookie(w) s.ClearCookie(w)
lg.Debug("elapsedTime doesn't parse", "err", err) 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 return
} }
@@ -497,15 +298,11 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
response := r.FormValue("response") response := r.FormValue("response")
urlParsed, err := r.URL.Parse(redir) urlParsed, err := r.URL.Parse(redir)
if err != nil { 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 return
} }
if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) { s.respondWithError(w, r, "Redirect domain not allowed")
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 return
} }
@@ -515,7 +312,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
s.ClearCookie(w) s.ClearCookie(w)
lg.Debug("nonce doesn't parse", "err", err) 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 return
} }
@@ -525,7 +322,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
s.ClearCookie(w) s.ClearCookie(w)
lg.Debug("hash does not match", "got", response, "want", calculated) 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() failedValidations.Inc()
return 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)) { if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
s.ClearCookie(w) s.ClearCookie(w)
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty) 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() failedValidations.Inc()
return return
} }
@@ -557,7 +354,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
lg.Error("failed to sign JWT", "err", err) lg.Error("failed to sign JWT", "err", err)
s.ClearCookie(w) 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 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) { func (s *Server) TestError(w http.ResponseWriter, r *http.Request) {
err := r.FormValue("err") 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 { func cr(name string, rule config.Rule) policy.CheckResult {

138
lib/config.go Normal file
View File

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

View File

@@ -2,8 +2,14 @@ package lib
import ( import (
"net/http" "net/http"
"slices"
"time" "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" "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 req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
return t.Transport.RoundTrip(req) 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)
}
}

View File

@@ -0,0 +1,79 @@
{
"bots": [
{
"name": "amazonbot",
"user_agent_regex": "Amazonbot",
"action": "DENY"
},
{
"name": "googlebot",
"user_agent_regex": "\\+http\\:\\/\\/www\\.google\\.com/bot\\.html",
"action": "ALLOW"
},
{
"name": "bingbot",
"user_agent_regex": "\\+http\\:\\/\\/www\\.bing\\.com/bingbot\\.htm",
"action": "ALLOW"
},
{
"name": "qwantbot",
"user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/",
"action": "ALLOW"
},
{
"name": "discordbot",
"user_agent_regex": "Discordbot/2\\.0; \\+https\\:\\/\\/discordapp\\.com",
"action": "ALLOW"
},
{
"name": "blueskybot",
"user_agent_regex": "Bluesky Cardyb",
"action": "ALLOW"
},
{
"name": "us-artificial-intelligence-scraper",
"user_agent_regex": "\\+https\\:\\/\\/github\\.com\\/US-Artificial-Intelligence\\/scraper",
"action": "DENY"
},
{
"name": "well-known",
"path_regex": "^/.well-known/.*$",
"action": "ALLOW"
},
{
"name": "favicon",
"path_regex": "^/favicon.ico$",
"action": "ALLOW"
},
{
"name": "robots-txt",
"path_regex": "^/robots.txt$",
"action": "ALLOW"
},
{
"name": "rss-readers",
"path_regex": ".*\\.(rss|xml|atom|json)$",
"action": "ALLOW"
},
{
"name": "lightpanda",
"user_agent_regex": "^Lightpanda/.*$",
"action": "DENY"
},
{
"name": "headless-chrome",
"user_agent_regex": "HeadlessChrome",
"action": "DENY"
},
{
"name": "headless-chromium",
"user_agent_regex": "HeadlessChromium",
"action": "DENY"
},
{
"name": "generic-browser",
"user_agent_regex": "Mozilla",
"action": "CHALLENGE"
}
]
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@techaro/anubis", "name": "@techaro/anubis",
"version": "1.0.0-see-VERSION-file", "version": "1.17.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {