Compare commits
23 Commits
v1.14.1
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e0a8179a | ||
|
|
f844fffd1e | ||
|
|
4b8efcba9b | ||
|
|
7d4be0dcec | ||
|
|
d1d63d9c18 | ||
|
|
ecc6b47f90 | ||
|
|
e7cbd349f3 | ||
|
|
07bb5f63f9 | ||
|
|
4155719422 | ||
|
|
f29a200f09 | ||
|
|
18cd8a66a2 | ||
|
|
725e11d3a6 | ||
|
|
f462209b02 | ||
|
|
acf5586e83 | ||
|
|
9d68e73d03 | ||
|
|
6156d3d729 | ||
|
|
af6f05554f | ||
|
|
1509b06cb9 | ||
|
|
56cdb2e51b | ||
|
|
15d801be7d | ||
|
|
c66305904b | ||
|
|
5f7942faca | ||
|
|
869e46a4cc |
44
.github/workflows/docker-pr.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Docker image builds (pull requests)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
|
||||
- uses: ko-build/setup-ko@v0.8
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/techarohq/anubis
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
run: |
|
||||
go run ./cmd/containerbuild --docker-repo ghcr.io/techarohq/anubis --slog-level debug
|
||||
env:
|
||||
PULL_REQUEST_ID: ${{ github.event.number }}
|
||||
|
||||
- run: |
|
||||
echo "Test this with:"
|
||||
echo "docker pull ${{ steps.build.outputs.docker_image }}"
|
||||
22
.github/workflows/docker.yml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
||||
@@ -28,12 +26,6 @@ jobs:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
@@ -57,20 +49,6 @@ jobs:
|
||||
id: build
|
||||
run: |
|
||||
go run ./cmd/containerbuild --docker-repo ghcr.io/techarohq/anubis --slog-level debug
|
||||
env:
|
||||
PULL_REQUEST_ID: ${{ github.event.number }}
|
||||
|
||||
# - name: "Comment about where to test this"
|
||||
# uses: thollander/actions-comment-pull-request@v3
|
||||
# if: ${{github.event_name == 'pull_request'}}
|
||||
# with:
|
||||
# message: |
|
||||
# You can try this PR out by using the following docker image:
|
||||
|
||||
# ```
|
||||
# ${{ steps.build.outputs.docker_image }}
|
||||
# ```
|
||||
# comment-tag: ${{ steps.build.outputs.docker_image }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
|
||||
15
.github/workflows/go.yml
vendored
@@ -56,8 +56,21 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-golang-
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
run: |
|
||||
npx --yes playwright@1.50.1 install --with-deps
|
||||
npx --yes playwright@1.50.1 run-server --port 3000 &
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
run: go test -v ./...
|
||||
|
||||
6
.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
.env
|
||||
*.rpm
|
||||
*.rpm
|
||||
|
||||
# Go binaries and test artifacts
|
||||
main
|
||||
*.test
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Anubis
|
||||
|
||||
<center>
|
||||
<img width=256 src="./cmd/anubis/static/img/happy.webp" alt="A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up" />
|
||||
<img width=256 src="./web/static/img/happy.webp" alt="A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up" />
|
||||
</center>
|
||||
|
||||

|
||||
|
||||
19
anubis.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Package Anubis contains the version number of Anubis.
|
||||
package anubis
|
||||
|
||||
// Version is the current version of Anubis.
|
||||
//
|
||||
// This variable is set at build time using the -X linker flag. If not set,
|
||||
// it defaults to "devel".
|
||||
var Version = "devel"
|
||||
|
||||
// CookieName is the name of the cookie that Anubis uses in order to validate
|
||||
// access.
|
||||
const CookieName = "within.website-x-cmd-anubis-auth"
|
||||
|
||||
// StaticPath is the location where all static Anubis assets are located.
|
||||
const StaticPath = "/.within.website/x/cmd/anubis/"
|
||||
|
||||
// DefaultDifficulty is the default "difficulty" (number of leading zeroes)
|
||||
// that must be met by the client in order to pass the challenge.
|
||||
const DefaultDifficulty = 4
|
||||
@@ -1,5 +0,0 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2025-01-24
|
||||
|
||||
- Added support for custom bot policy documentation, allowing administrators to change how Anubis works to meet their needs.
|
||||
@@ -4,18 +4,11 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
mrand "math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -29,73 +22,44 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/config"
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/dnsbl"
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/xess"
|
||||
"github.com/a-h/templ"
|
||||
libanubis "github.com/TecharoHQ/anubis/lib"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/TecharoHQ/anubis/web"
|
||||
"github.com/facebookgo/flagenv"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
bind = flag.String("bind", ":8923", "network address to bind HTTP to")
|
||||
bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
|
||||
challengeDifficulty = flag.Int("difficulty", defaultDifficulty, "difficulty of the challenge")
|
||||
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
|
||||
metricsBindNetwork = flag.String("metrics-bind-network", "tcp", "network family for the metrics server to bind to")
|
||||
socketMode = flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
|
||||
robotsTxt = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots")
|
||||
policyFname = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
|
||||
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
target = flag.String("target", "http://localhost:3923", "target to reverse proxy to")
|
||||
healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis")
|
||||
debugXRealIPDefault = flag.String("debug-x-real-ip-default", "", "If set, replace empty X-Real-Ip headers with this value, useful only for debugging Anubis and running it locally")
|
||||
|
||||
//go:embed static botPolicies.json
|
||||
static embed.FS
|
||||
|
||||
challengesIssued = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_issued",
|
||||
Help: "The total number of challenges issued",
|
||||
})
|
||||
|
||||
challengesValidated = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_validated",
|
||||
Help: "The total number of challenges validated",
|
||||
})
|
||||
|
||||
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_dronebl_hits",
|
||||
Help: "The total number of hits from DroneBL",
|
||||
}, []string{"status"})
|
||||
|
||||
failedValidations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "anubis_failed_validations",
|
||||
Help: "The total number of failed validations",
|
||||
})
|
||||
|
||||
timeTaken = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "anubis_time_taken",
|
||||
Help: "The time taken for a browser to generate a response (milliseconds)",
|
||||
Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 18), 19),
|
||||
})
|
||||
bind = flag.String("bind", ":8923", "network address to bind HTTP to")
|
||||
bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
|
||||
challengeDifficulty = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
|
||||
cookieDomain = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
|
||||
cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
|
||||
ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
|
||||
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
|
||||
metricsBindNetwork = flag.String("metrics-bind-network", "tcp", "network family for the metrics server to bind to")
|
||||
socketMode = flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
|
||||
robotsTxt = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots")
|
||||
policyFname = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
|
||||
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
target = flag.String("target", "http://localhost:3923", "target to reverse proxy to")
|
||||
healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis")
|
||||
debugXRealIPDefault = flag.String("debug-x-real-ip-default", "", "If set, replace empty X-Real-Ip headers with this value, useful only for debugging Anubis and running it locally")
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "within.website-x-cmd-anubis-auth"
|
||||
staticPath = "/.within.website/x/cmd/anubis/"
|
||||
defaultDifficulty = 4
|
||||
)
|
||||
func keyFromHex(value string) (ed25519.PrivateKey, error) {
|
||||
keyBytes, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("supplied key is not hex-encoded: %w", err)
|
||||
}
|
||||
|
||||
//go:generate go tool github.com/a-h/templ/cmd/templ generate
|
||||
//go:generate esbuild js/main.mjs --sourcemap --bundle --minify --outfile=static/js/main.mjs
|
||||
//go:generate gzip -f -k static/js/main.mjs
|
||||
//go:generate zstd -f -k --ultra -22 static/js/main.mjs
|
||||
//go:generate brotli -fZk static/js/main.mjs
|
||||
if len(keyBytes) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("supplied key is not %d bytes long, got %d bytes", ed25519.SeedSize, len(keyBytes))
|
||||
}
|
||||
|
||||
return ed25519.NewKeyFromSeed(keyBytes), nil
|
||||
}
|
||||
|
||||
func doHealthCheck() error {
|
||||
resp, err := http.Get("http://localhost" + *metricsBind + "/metrics")
|
||||
@@ -145,6 +109,34 @@ func setupListener(network string, address string) (net.Listener, string) {
|
||||
return listener, formattedAddress
|
||||
}
|
||||
|
||||
func makeReverseProxy(target string) (http.Handler, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
|
||||
if u.Scheme == "unix" {
|
||||
// clean path up so we don't use the socket path in proxied requests
|
||||
addr := u.Path
|
||||
u.Path = ""
|
||||
// tell transport how to dial unix sockets
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", addr)
|
||||
}
|
||||
// tell transport how to handle the unix url scheme
|
||||
transport.RegisterProtocol("unix", libanubis.UnixRoundTripper{Transport: transport})
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = transport
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flagenv.Parse()
|
||||
flag.Parse()
|
||||
@@ -158,13 +150,18 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := New(*target, *policyFname)
|
||||
rp, err := makeReverseProxy(*target)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("can't make reverse proxy: %v", err)
|
||||
}
|
||||
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, *challengeDifficulty)
|
||||
if err != nil {
|
||||
log.Fatalf("can't parse policy file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Rule error IDs:")
|
||||
for _, rule := range s.policy.Bots {
|
||||
for _, rule := range policy.Bots {
|
||||
if rule.Action != config.RuleDeny {
|
||||
continue
|
||||
}
|
||||
@@ -178,25 +175,31 @@ func main() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
var priv ed25519.PrivateKey
|
||||
if *ed25519PrivateKeyHex == "" {
|
||||
_, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to generate ed25519 key: %v", err)
|
||||
}
|
||||
|
||||
mux.Handle(staticPath, internal.UnchangingCache(http.StripPrefix(staticPath, http.FileServerFS(static))))
|
||||
slog.Warn("generating random key, Anubis will have strange behavior when multiple instances are behind the same load balancer target, for more information: see https://anubis.techaro.lol/docs/admin/installation#key-generation")
|
||||
} else {
|
||||
priv, err = keyFromHex(*ed25519PrivateKeyHex)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse and validate ED25519_PRIVATE_KEY_HEX: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding)
|
||||
|
||||
mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", s.makeChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", s.passChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", s.testError)
|
||||
|
||||
if *robotsTxt {
|
||||
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, static, "static/robots.txt")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/.well-known/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, static, "static/robots.txt")
|
||||
})
|
||||
s, err := libanubis.New(libanubis.Options{
|
||||
Next: rp,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: *robotsTxt,
|
||||
PrivateKey: priv,
|
||||
CookieDomain: *cookieDomain,
|
||||
CookiePartitioned: *cookiePartitioned,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
wg := new(sync.WaitGroup)
|
||||
@@ -209,10 +212,8 @@ func main() {
|
||||
go metricsServer(ctx, wg.Done)
|
||||
}
|
||||
|
||||
mux.HandleFunc("/", s.maybeReverseProxy)
|
||||
|
||||
var h http.Handler
|
||||
h = mux
|
||||
h = s
|
||||
h = internal.DefaultXRealIP(*debugXRealIPDefault, h)
|
||||
h = internal.XForwardedForToXRealIP(h)
|
||||
|
||||
@@ -267,428 +268,6 @@ func metricsServer(ctx context.Context, done func()) {
|
||||
}
|
||||
}
|
||||
|
||||
func sha256sum(text string) string {
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(text))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
|
||||
fp := sha256.Sum256(s.priv.Seed())
|
||||
|
||||
data := fmt.Sprintf(
|
||||
"Accept-Language=%s,X-Real-IP=%s,User-Agent=%s,WeekTime=%s,Fingerprint=%x,Difficulty=%d",
|
||||
r.Header.Get("Accept-Language"),
|
||||
r.Header.Get("X-Real-Ip"),
|
||||
r.UserAgent(),
|
||||
time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339),
|
||||
fp,
|
||||
difficulty,
|
||||
)
|
||||
return sha256sum(data)
|
||||
}
|
||||
|
||||
func New(target, policyFname string) (*Server, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate ed25519 key: %w", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
|
||||
if u.Scheme == "unix" {
|
||||
// clean path up so we don't use the socket path in proxied requests
|
||||
addr := u.Path
|
||||
u.Path = ""
|
||||
// tell transport how to dial unix sockets
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", addr)
|
||||
}
|
||||
// tell transport how to handle the unix url scheme
|
||||
transport.RegisterProtocol("unix", unixRoundTripper{Transport: transport})
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = transport
|
||||
|
||||
var fin io.ReadCloser
|
||||
|
||||
if policyFname != "" {
|
||||
fin, err = os.Open(policyFname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy file %s: %w", policyFname, err)
|
||||
}
|
||||
} else {
|
||||
policyFname = "(static)/botPolicies.json"
|
||||
fin, err = static.Open("botPolicies.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", policyFname, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer fin.Close()
|
||||
|
||||
policy, err := parseConfig(fin, policyFname, *challengeDifficulty)
|
||||
if err != nil {
|
||||
return nil, err // parseConfig sets a fancy error for us
|
||||
}
|
||||
|
||||
return &Server{
|
||||
rp: rp,
|
||||
priv: priv,
|
||||
pub: pub,
|
||||
policy: policy,
|
||||
dnsblCache: NewDecayMap[string, dnsbl.DroneBLResponse](),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
|
||||
type unixRoundTripper struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// set bare minimum stuff
|
||||
func (t unixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Host == "" {
|
||||
req.Host = "localhost"
|
||||
}
|
||||
req.URL.Host = req.Host // proxy error: no Host in request URL
|
||||
req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
|
||||
return t.Transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
rp *httputil.ReverseProxy
|
||||
priv ed25519.PrivateKey
|
||||
pub ed25519.PublicKey
|
||||
policy *ParsedConfig
|
||||
dnsblCache *DecayMap[string, dnsbl.DroneBLResponse]
|
||||
}
|
||||
|
||||
func (s *Server) maybeReverseProxy(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"),
|
||||
)
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy\"")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Add("X-Anubis-Rule", cr.Name)
|
||||
r.Header.Add("X-Anubis-Action", string(cr.Rule))
|
||||
lg = lg.With("check_result", cr)
|
||||
policyApplications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
||||
|
||||
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(base("Oh noes!", errorPage(fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch cr.Rule {
|
||||
case config.RuleAllow:
|
||||
lg.Debug("allowing traffic to origin (explicit)")
|
||||
s.rp.ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleDeny:
|
||||
clearCookie(w)
|
||||
lg.Info("explicit deny")
|
||||
if rule == nil {
|
||||
lg.Error("rule is nil, cannot calculate checksum")
|
||||
templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
hash, err := rule.Hash()
|
||||
if err != nil {
|
||||
lg.Error("can't calculate checksum of rule", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg.Debug("rule hash", "hash", hash)
|
||||
templ.Handler(base("Oh noes!", errorPage(fmt.Sprintf("Access Denied: error code %s", hash))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleChallenge:
|
||||
lg.Debug("challenge requested")
|
||||
default:
|
||||
clearCookie(w)
|
||||
templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ckie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ckie.Valid(); err != nil {
|
||||
lg.Debug("cookie is invalid", "err", err)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return s.pub, nil
|
||||
}, jwt.WithExpirationRequired(), jwt.WithStrictDecoding())
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if randomJitter() {
|
||||
r.Header.Add("X-Anubis-Status", "PASS-BRIEF")
|
||||
lg.Debug("cookie is not enrolled into secondary screening")
|
||||
s.rp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
lg.Debug("invalid token claims type", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
if claims["challenge"] != challenge {
|
||||
lg.Debug("invalid challenge", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var nonce int
|
||||
|
||||
if v, ok := claims["nonce"].(float64); ok {
|
||||
nonce = int(v)
|
||||
}
|
||||
|
||||
calcString := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
calculated := sha256sum(calcString)
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 {
|
||||
lg.Debug("invalid response", "path", r.URL.Path)
|
||||
failedValidations.Inc()
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("all checks passed")
|
||||
r.Header.Add("X-Anubis-Status", "PASS-FULL")
|
||||
s.rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) renderIndex(w http.ResponseWriter, r *http.Request) {
|
||||
templ.Handler(
|
||||
base("Making sure you're not a bot!", index()),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Error string `json:"error"`
|
||||
}{
|
||||
Error: "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"makeChallenge\"",
|
||||
})
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr)
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Rules *config.ChallengeRules `json:"rules"`
|
||||
}{
|
||||
Challenge: challenge,
|
||||
Rules: rule.Challenge,
|
||||
})
|
||||
lg.Debug("made challenge", "challenge", challenge, "rules", rule.Challenge, "cr", cr)
|
||||
challengesIssued.Inc()
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr)
|
||||
|
||||
nonceStr := r.FormValue("nonce")
|
||||
if nonceStr == "" {
|
||||
clearCookie(w)
|
||||
lg.Debug("no nonce")
|
||||
templ.Handler(base("Oh noes!", errorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTimeStr := r.FormValue("elapsedTime")
|
||||
if elapsedTimeStr == "" {
|
||||
clearCookie(w)
|
||||
lg.Debug("no elapsedTime")
|
||||
templ.Handler(base("Oh noes!", errorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
||||
if err != nil {
|
||||
clearCookie(w)
|
||||
lg.Debug("elapsedTime doesn't parse", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
lg.Info("challenge took", "elapsedTime", elapsedTime)
|
||||
timeTaken.Observe(elapsedTime)
|
||||
|
||||
response := r.FormValue("response")
|
||||
redir := r.FormValue("redir")
|
||||
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
nonce, err := strconv.Atoi(nonceStr)
|
||||
if err != nil {
|
||||
clearCookie(w)
|
||||
lg.Debug("nonce doesn't parse", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
calcString := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
calculated := sha256sum(calcString)
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
||||
clearCookie(w)
|
||||
lg.Debug("hash does not match", "got", response, "want", calculated)
|
||||
templ.Handler(base("Oh noes!", errorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
|
||||
failedValidations.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// compare the leading zeroes
|
||||
if !strings.HasPrefix(response, strings.Repeat("0", *challengeDifficulty)) {
|
||||
clearCookie(w)
|
||||
lg.Debug("difficulty check failed", "response", response, "difficulty", *challengeDifficulty)
|
||||
templ.Handler(base("Oh noes!", errorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
|
||||
failedValidations.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// generate JWT cookie
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
|
||||
"challenge": challenge,
|
||||
"nonce": nonce,
|
||||
"response": response,
|
||||
"iat": time.Now().Unix(),
|
||||
"nbf": time.Now().Add(-1 * time.Minute).Unix(),
|
||||
"exp": time.Now().Add(24 * 7 * time.Hour).Unix(),
|
||||
})
|
||||
tokenString, err := token.SignedString(s.priv)
|
||||
if err != nil {
|
||||
lg.Error("failed to sign JWT", "err", err)
|
||||
clearCookie(w)
|
||||
templ.Handler(base("Oh noes!", errorPage("failed to sign JWT")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(24 * 7 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
challengesValidated.Inc()
|
||||
lg.Debug("challenge passed, redirecting to app")
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) testError(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.FormValue("err")
|
||||
templ.Handler(base("Oh noes!", errorPage(err)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func ohNoes(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error("super fatal error", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("An internal server error happened")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func clearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func randomJitter() bool {
|
||||
return mrand.Intn(100) > 10
|
||||
}
|
||||
|
||||
func serveMainJSWithBestEncoding(w http.ResponseWriter, r *http.Request) {
|
||||
priorityList := []string{"zstd", "br", "gzip"}
|
||||
enc2ext := map[string]string{
|
||||
@@ -701,11 +280,11 @@ func serveMainJSWithBestEncoding(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), enc) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Content-Encoding", enc)
|
||||
http.ServeFileFS(w, r, static, "static/js/main.mjs."+enc2ext[enc])
|
||||
http.ServeFileFS(w, r, web.Static, "static/js/main.mjs."+enc2ext[enc])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
http.ServeFileFS(w, r, static, "static/js/main.mjs")
|
||||
http.ServeFileFS(w, r, web.Static, "static/js/main.mjs")
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/config"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/yl2chen/cidranger"
|
||||
)
|
||||
|
||||
var (
|
||||
policyApplications = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_policy_results",
|
||||
Help: "The results of each policy rule",
|
||||
}, []string{"rule", "action"})
|
||||
)
|
||||
|
||||
type ParsedConfig struct {
|
||||
orig config.Config
|
||||
|
||||
Bots []Bot
|
||||
DNSBL bool
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
Name string
|
||||
UserAgent *regexp.Regexp
|
||||
Path *regexp.Regexp
|
||||
Action config.Rule `json:"action"`
|
||||
Challenge *config.ChallengeRules
|
||||
Ranger cidranger.Ranger
|
||||
}
|
||||
|
||||
func (b Bot) Hash() (string, error) {
|
||||
var pathRex string
|
||||
if b.Path != nil {
|
||||
pathRex = b.Path.String()
|
||||
}
|
||||
var userAgentRex string
|
||||
if b.UserAgent != nil {
|
||||
userAgentRex = b.UserAgent.String()
|
||||
}
|
||||
|
||||
return sha256sum(fmt.Sprintf("%s::%s::%s", b.Name, pathRex, userAgentRex)), nil
|
||||
}
|
||||
|
||||
func parseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
|
||||
var c config.Config
|
||||
if err := json.NewDecoder(fin).Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy config JSON %s: %w", fname, err)
|
||||
}
|
||||
|
||||
if err := c.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
result := &ParsedConfig{
|
||||
orig: c,
|
||||
}
|
||||
|
||||
for _, b := range c.Bots {
|
||||
if berr := b.Valid(); berr != nil {
|
||||
err = errors.Join(err, berr)
|
||||
continue
|
||||
}
|
||||
|
||||
var botParseErr error
|
||||
parsedBot := Bot{
|
||||
Name: b.Name,
|
||||
Action: b.Action,
|
||||
}
|
||||
|
||||
if b.RemoteAddr != nil && len(b.RemoteAddr) > 0 {
|
||||
parsedBot.Ranger = cidranger.NewPCTrieRanger()
|
||||
|
||||
for _, cidr := range b.RemoteAddr {
|
||||
_, rng, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] range %s not parsing: %w", cidr, err)
|
||||
}
|
||||
|
||||
parsedBot.Ranger.Insert(cidranger.NewBasicRangerEntry(*rng))
|
||||
}
|
||||
}
|
||||
|
||||
if b.UserAgentRegex != nil {
|
||||
userAgent, err := regexp.Compile(*b.UserAgentRegex)
|
||||
if err != nil {
|
||||
botParseErr = errors.Join(botParseErr, fmt.Errorf("while compiling user agent regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.UserAgent = userAgent
|
||||
}
|
||||
}
|
||||
|
||||
if b.PathRegex != nil {
|
||||
path, err := regexp.Compile(*b.PathRegex)
|
||||
if err != nil {
|
||||
botParseErr = errors.Join(botParseErr, fmt.Errorf("while compiling path regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.Path = path
|
||||
}
|
||||
}
|
||||
|
||||
if b.Challenge == nil {
|
||||
parsedBot.Challenge = &config.ChallengeRules{
|
||||
Difficulty: defaultDifficulty,
|
||||
ReportAs: defaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
}
|
||||
} else {
|
||||
parsedBot.Challenge = b.Challenge
|
||||
if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown {
|
||||
parsedBot.Challenge.Algorithm = config.AlgorithmFast
|
||||
}
|
||||
}
|
||||
|
||||
result.Bots = append(result.Bots, parsedBot)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, err)
|
||||
}
|
||||
|
||||
result.DNSBL = c.DNSBL
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Rule config.Rule
|
||||
}
|
||||
|
||||
func (cr CheckResult) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.String("name", cr.Name),
|
||||
slog.String("rule", string(cr.Rule)))
|
||||
}
|
||||
|
||||
func cr(name string, rule config.Rule) CheckResult {
|
||||
return CheckResult{
|
||||
Name: name,
|
||||
Rule: rule,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) checkRemoteAddress(b Bot, addr net.IP) bool {
|
||||
if b.Ranger == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, err := b.Ranger.Contains(addr)
|
||||
if err != nil {
|
||||
log.Panicf("[unexpected] something very funky is going on, %q does not have a calculable network number: %v", addr.String(), err)
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// Check evaluates the list of rules, and returns the result
|
||||
func (s *Server) check(r *http.Request) (CheckResult, *Bot, error) {
|
||||
host := r.Header.Get("X-Real-Ip")
|
||||
if host == "" {
|
||||
return zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
|
||||
}
|
||||
|
||||
addr := net.ParseIP(host)
|
||||
if addr == nil {
|
||||
return zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
|
||||
}
|
||||
|
||||
for _, b := range s.policy.Bots {
|
||||
if b.UserAgent != nil {
|
||||
if uaMatch := b.UserAgent.MatchString(r.UserAgent()); uaMatch || (uaMatch && s.checkRemoteAddress(b, addr)) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Path != nil {
|
||||
if pathMatch := b.Path.MatchString(r.URL.Path); pathMatch || (pathMatch && s.checkRemoteAddress(b, addr)) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Ranger != nil {
|
||||
if s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cr("default/allow", config.RuleAllow), &Bot{
|
||||
Challenge: &config.ChallengeRules{
|
||||
Difficulty: defaultDifficulty,
|
||||
ReportAs: defaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,2 +0,0 @@
|
||||
(()=>{function p(r,n=5,t=navigator.hardwareConcurrency||1){return console.debug("fast algo"),new Promise((e,o)=>{let s=URL.createObjectURL(new Blob(["(",y(),")()"],{type:"application/javascript"})),a=[];for(let i=0;i<t;i++){let c=new Worker(s);c.onmessage=d=>{a.forEach(u=>u.terminate()),c.terminate(),e(d.data)},c.onerror=d=>{c.terminate(),o()},c.postMessage({data:r,difficulty:n,nonce:i,threads:t}),a.push(c)}URL.revokeObjectURL(s)})}function y(){return function(){let r=t=>{let e=new TextEncoder().encode(t);return crypto.subtle.digest("SHA-256",e.buffer)};function n(t){return Array.from(t).map(e=>e.toString(16).padStart(2,"0")).join("")}addEventListener("message",async t=>{let e=t.data.data,o=t.data.difficulty,s,a=t.data.nonce,i=t.data.threads;for(;;){let c=await r(e+a),d=new Uint8Array(c),u=!0;for(let m=0;m<o;m++){let l=Math.floor(m/2),g=m%2;if((d[l]>>(g===0?4:0)&15)!==0){u=!1;break}}if(u){s=n(d),console.log(s);break}a+=i}postMessage({hash:s,data:e,difficulty:o,nonce:a})})}.toString()}function f(r,n=5,t=1){return console.debug("slow algo"),new Promise((e,o)=>{let s=URL.createObjectURL(new Blob(["(",b(),")()"],{type:"application/javascript"})),a=new Worker(s);a.onmessage=i=>{a.terminate(),e(i.data)},a.onerror=i=>{a.terminate(),o()},a.postMessage({data:r,difficulty:n}),URL.revokeObjectURL(s)})}function b(){return function(){let r=n=>{let t=new TextEncoder().encode(n);return crypto.subtle.digest("SHA-256",t.buffer).then(e=>Array.from(new Uint8Array(e)).map(o=>o.toString(16).padStart(2,"0")).join(""))};addEventListener("message",async n=>{let t=n.data.data,e=n.data.difficulty,o,s=0;do o=await r(t+s++);while(o.substring(0,e)!==Array(e+1).join("0"));s-=1,postMessage({hash:o,data:t,difficulty:e,nonce:s})})}.toString()}var L={fast:p,slow:f},w=(r="",n={})=>{let t=new URL(r,window.location.href);return Object.entries(n).forEach(e=>{let[o,s]=e;t.searchParams.set(o,s)}),t.toString()},h=(r,n)=>w(`/.within.website/x/cmd/anubis/static/img/${r}.webp`,{cacheBuster:n});(async()=>{let r=document.getElementById("status"),n=document.getElementById("image"),t=document.getElementById("title"),e=document.getElementById("spinner"),o=JSON.parse(document.getElementById("anubis_version").textContent);r.innerHTML="Calculating...";let{challenge:s,rules:a}=await fetch("/.within.website/x/cmd/anubis/api/make-challenge",{method:"POST"}).then(l=>{if(!l.ok)throw new Error("Failed to fetch config");return l.json()}).catch(l=>{throw t.innerHTML="Oh no!",r.innerHTML=`Failed to fetch config: ${l.message}`,n.src=h("sad",o),e.innerHTML="",e.style.display="none",l}),i=L[a.algorithm];if(!i){t.innerHTML="Oh no!",r.innerHTML="Failed to resolve check algorithm. You may want to reload the page.",n.src=h("sad",o),e.innerHTML="",e.style.display="none";return}r.innerHTML=`Calculating...<br/>Difficulty: ${a.report_as}`;let c=Date.now(),{hash:d,nonce:u}=await i(s,a.difficulty),m=Date.now();console.log({hash:d,nonce:u}),t.innerHTML="Success!",r.innerHTML=`Done! Took ${m-c}ms, ${u} iterations`,n.src=h("happy",o),e.innerHTML="",e.style.display="none",setTimeout(()=>{let l=window.location.href;window.location.href=w("/.within.website/x/cmd/anubis/api/pass-challenge",{response:d,nonce:u,redir:l,elapsedTime:m-c})},250)})();})();
|
||||
//# sourceMappingURL=main.mjs.map
|
||||
@@ -23,21 +23,8 @@ var (
|
||||
githubEventName = flag.String("github-event-name", "", "GitHub event name")
|
||||
pullRequestID = flag.Int("pull-request-id", -1, "GitHub pull request ID")
|
||||
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
|
||||
knownContributors = []string{
|
||||
"Xe",
|
||||
}
|
||||
)
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
flagenv.Parse()
|
||||
flag.Parse()
|
||||
@@ -46,11 +33,7 @@ func main() {
|
||||
|
||||
koDockerRepo := strings.TrimRight(*dockerRepo, "/"+filepath.Base(*dockerRepo))
|
||||
|
||||
if *githubEventName == "pull_request" && !inList(*githubActor, knownContributors) {
|
||||
if *pullRequestID == -1 {
|
||||
log.Fatal("Must set --pull-request-id when --github-event-name=pull_request")
|
||||
}
|
||||
|
||||
if *githubEventName == "pull_request" && *pullRequestID != -1 {
|
||||
*dockerRepo = fmt.Sprintf("ttl.sh/techaro/pr-%d/anubis", *pullRequestID)
|
||||
*dockerTags = fmt.Sprintf("ttl.sh/techaro/pr-%d/anubis:24h", *pullRequestID)
|
||||
koDockerRepo = fmt.Sprintf("ttl.sh/techaro/pr-%d", *pullRequestID)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googlebot",
|
||||
"user_agent_regex": "\\+http\\:\\/\\/www\\.google\\.com/bot\\.html",
|
||||
"user_agent_regex": "\\+http\\://www\\.google\\.com/bot\\.html",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"2001:4860:4801:10::/64",
|
||||
@@ -271,7 +271,7 @@
|
||||
},
|
||||
{
|
||||
"name": "bingbot",
|
||||
"user_agent_regex": "\\+http\\:\\/\\/www\\.bing\\.com/bingbot\\.htm",
|
||||
"user_agent_regex": "\\+http\\://www\\.bing\\.com/bingbot\\.htm",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"157.55.39.0/24",
|
||||
@@ -306,7 +306,7 @@
|
||||
},
|
||||
{
|
||||
"name": "qwantbot",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/",
|
||||
"user_agent_regex": "\\+https\\://help\\.qwant\\.com/bot/",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"91.242.162.0/24"
|
||||
@@ -314,7 +314,7 @@
|
||||
},
|
||||
{
|
||||
"name": "kagibot",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/kagi\\.com/bot",
|
||||
"user_agent_regex": "\\+https\\://kagi\\.com/bot",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"216.18.205.234/32",
|
||||
@@ -335,9 +335,17 @@
|
||||
"193.183.0.174/32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mojeekbot",
|
||||
"user_agent_regex": "http\\://www\\.mojeek\\.com/bot\\.html",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"5.102.173.71/32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "us-artificial-intelligence-scraper",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/github\\.com\\/US-Artificial-Intelligence\\/scraper",
|
||||
"user_agent_regex": "\\+https\\://github\\.com/US-Artificial-Intelligence/scraper",
|
||||
"action": "DENY"
|
||||
},
|
||||
{
|
||||
@@ -355,11 +363,6 @@
|
||||
"path_regex": "^/robots.txt$",
|
||||
"action": "ALLOW"
|
||||
},
|
||||
{
|
||||
"name": "rss-readers",
|
||||
"path_regex": ".*\\.(rss|xml|atom|json)$",
|
||||
"action": "ALLOW"
|
||||
},
|
||||
{
|
||||
"name": "lightpanda",
|
||||
"user_agent_regex": "^Lightpanda/.*$",
|
||||
@@ -392,4 +395,4 @@
|
||||
}
|
||||
],
|
||||
"dnsbl": true
|
||||
}
|
||||
}
|
||||
8
data/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package data
|
||||
|
||||
import "embed"
|
||||
|
||||
var (
|
||||
//go:embed botPolicies.json
|
||||
BotPolicies embed.FS
|
||||
)
|
||||
@@ -1,17 +1,17 @@
|
||||
package main
|
||||
package decaymap
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func zilch[T any]() T {
|
||||
func Zilch[T any]() T {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
// DecayMap is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time.
|
||||
type DecayMap[K comparable, V any] struct {
|
||||
// Impl is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time.
|
||||
type Impl[K comparable, V any] struct {
|
||||
data map[K]decayMapEntry[V]
|
||||
lock sync.RWMutex
|
||||
}
|
||||
@@ -21,17 +21,17 @@ type decayMapEntry[V any] struct {
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// NewDecayMap creates a new DecayMap of key type K and value type V.
|
||||
// New creates a new DecayMap of key type K and value type V.
|
||||
//
|
||||
// Key types must be comparable to work with maps.
|
||||
func NewDecayMap[K comparable, V any]() *DecayMap[K, V] {
|
||||
return &DecayMap[K, V]{
|
||||
func New[K comparable, V any]() *Impl[K, V] {
|
||||
return &Impl[K, V]{
|
||||
data: make(map[K]decayMapEntry[V]),
|
||||
}
|
||||
}
|
||||
|
||||
// expire forcibly expires a key by setting its time-to-live one second in the past.
|
||||
func (m *DecayMap[K, V]) expire(key K) bool {
|
||||
func (m *Impl[K, V]) expire(key K) bool {
|
||||
m.lock.RLock()
|
||||
val, ok := m.data[key]
|
||||
m.lock.RUnlock()
|
||||
@@ -51,32 +51,32 @@ func (m *DecayMap[K, V]) expire(key K) bool {
|
||||
// Get gets a value from the DecayMap by key.
|
||||
//
|
||||
// If a value has expired, forcibly delete it if it was not updated.
|
||||
func (m *DecayMap[K, V]) Get(key K) (V, bool) {
|
||||
func (m *Impl[K, V]) Get(key K) (V, bool) {
|
||||
m.lock.RLock()
|
||||
value, ok := m.data[key]
|
||||
m.lock.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return zilch[V](), false
|
||||
return Zilch[V](), false
|
||||
}
|
||||
|
||||
if time.Now().After(value.expiry) {
|
||||
m.lock.Lock()
|
||||
// Since previously reading m.data[key], the value may have been updated.
|
||||
// Delete the entry only if the expiry time is still the same.
|
||||
if m.data[key].expiry == value.expiry {
|
||||
if m.data[key].expiry.Equal(value.expiry) {
|
||||
delete(m.data, key)
|
||||
}
|
||||
m.lock.Unlock()
|
||||
|
||||
return zilch[V](), false
|
||||
return Zilch[V](), false
|
||||
}
|
||||
|
||||
return value.Value, true
|
||||
}
|
||||
|
||||
// Set sets a key value pair in the map.
|
||||
func (m *DecayMap[K, V]) Set(key K, value V, ttl time.Duration) {
|
||||
func (m *Impl[K, V]) Set(key K, value V, ttl time.Duration) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
package decaymap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecayMap(t *testing.T) {
|
||||
dm := NewDecayMap[string, string]()
|
||||
func TestImpl(t *testing.T) {
|
||||
dm := New[string, string]()
|
||||
|
||||
dm.Set("test", "hi", 5*time.Minute)
|
||||
|
||||
8
doc.go
@@ -1,8 +0,0 @@
|
||||
// Package Anubis contains the version number of Anubis.
|
||||
package anubis
|
||||
|
||||
// Version is the current version of Anubis.
|
||||
//
|
||||
// This variable is set at build time using the -X linker flag. If not set,
|
||||
// it defaults to "devel".
|
||||
var Version = "devel"
|
||||
@@ -11,6 +11,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v1.15.2
|
||||
|
||||
Zenos yae Galvus: Echo 2
|
||||
|
||||
The mascot has been updated with art by [CELPHASE](https://bsky.app/profile/celphase.bsky.social).
|
||||
|
||||
## v1.15.1
|
||||
|
||||
Zenos yae Galvus: Echo 1
|
||||
|
||||
Fixes a recurrence of [CVE-2025-24369](https://github.com/Xe/x/security/advisories/GHSA-56w8-8ppj-2p4f)
|
||||
due to an incorrect logic change in a refactor. This allows an attacker to mint a valid
|
||||
access token by passing any SHA-256 hash instead of one that matches the proof-of-work
|
||||
test.
|
||||
|
||||
This case has been added as a regression test. It was not when CVE-2025-24369 was released
|
||||
due to the project not having the maturity required to enable this kind of regression testing.
|
||||
|
||||
## v1.15.0
|
||||
|
||||
Zenos yae Galvus
|
||||
|
||||
> Yes...the coming days promise to be most interesting. Most interesting.
|
||||
|
||||
Headline changes:
|
||||
|
||||
- ed25519 signing keys for Anubis can be stored in the flag `--ed25519-private-key-hex` or envvar `ED25519_PRIVATE_KEY_HEX`; if one is not provided when Anubis starts, a new one is generated and logged
|
||||
- Add the ability to set the cookie domain with the envvar `COOKIE_DOMAIN=techaro.lol` for all domains under `techaro.lol`
|
||||
- Add the ability to set the cookie partitioned flag with the envvar `COOKIE_PARTITIONED=true`
|
||||
|
||||
Many other small changes were made, including but not limited to:
|
||||
|
||||
- Fixed and clarified installation instructions
|
||||
- Introduced integration tests using Playwright
|
||||
- Refactor & Split up Anubis into cmd and lib.go
|
||||
- Fixed bot check to only apply if address range matches
|
||||
- Fix default difficulty setting that was broken in a refactor
|
||||
- Linting fixes
|
||||
- Make dark mode diff lines readable in the documentation
|
||||
- Fix CI based browser smoke test
|
||||
|
||||
Users running Anubis' test suite may run into issues with the integration tests on Windows hosts. This is a known issue and will be fixed at some point in the future. In the meantime, use the Windows Subsystem for Linux (WSL).
|
||||
|
||||
## v1.14.2
|
||||
|
||||
Livia sas Junius: Echo 2
|
||||
|
||||
- Remove default RSS reader rule as it may allow for a targeted attack against rails apps
|
||||
[#67](https://github.com/TecharoHQ/anubis/pull/67)
|
||||
- Whitelist MojeekBot in botPolicies [#47](https://github.com/TecharoHQ/anubis/issues/47)
|
||||
- botPolicies regex has been cleaned up [#66](https://github.com/TecharoHQ/anubis/pull/66)
|
||||
|
||||
## v1.14.1
|
||||
|
||||
Livia sas Junius: Echo 1
|
||||
|
||||
@@ -2,8 +2,28 @@
|
||||
title: Setting up Anubis
|
||||
---
|
||||
|
||||
import RandomKey from "@site/src/components/RandomKey";
|
||||
|
||||
Anubis is meant to sit between your reverse proxy (such as Nginx or Caddy) and your target service. One instance of Anubis must be used per service you are protecting.
|
||||
|
||||
<center>
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: With Anubis installed
|
||||
---
|
||||
|
||||
flowchart LR
|
||||
LB(Load balancer /
|
||||
TLS terminator)
|
||||
Anubis(Anubis)
|
||||
App(App)
|
||||
|
||||
LB --> Anubis --> App
|
||||
```
|
||||
|
||||
</center>
|
||||
|
||||
Anubis is shipped in the Docker repo [`ghcr.io/techarohq/anubis`](https://github.com/TecharoHQ/anubis/pkgs/container/anubis). The following tags exist for your convenience:
|
||||
|
||||
| Tag | Meaning |
|
||||
@@ -21,17 +41,32 @@ Anubis has very minimal system requirements. I suspect that 128Mi of ram may be
|
||||
|
||||
Anubis uses these environment variables for configuration:
|
||||
|
||||
| Environment Variable | Default value | Explanation |
|
||||
| :--------------------- | :------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
|
||||
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
|
||||
| `DIFFICULTY` | `5` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
|
||||
| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. |
|
||||
| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
|
||||
| `SOCKET_MODE` | `0770` | *Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`.* The socket mode (permissions) for Unix domain sockets. |
|
||||
| `POLICY_FNAME` | `/data/cfg/botPolicy.json` | The file containing [bot policy configuration](./policies.md). See the bot policy documentation for more details. |
|
||||
| `SERVE_ROBOTS_TXT` | `false` | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. |
|
||||
| `TARGET` | `http://localhost:3923` | The URL of the service that Anubis should forward valid requests to. Supports Unix domain sockets, set this to a URI like so: `unix:///path/to/socket.sock`. |
|
||||
| Environment Variable | Default value | Explanation |
|
||||
| :------------------------ | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
|
||||
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
|
||||
| `COOKIE_DOMAIN` | unset | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See [here](https://stackoverflow.com/a/1063760) for more information. |
|
||||
| `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. |
|
||||
| `DIFFICULTY` | `5` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
|
||||
| `ED25519_PRIVATE_KEY_HEX` | | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. See below for details. |
|
||||
| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. |
|
||||
| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
|
||||
| `SOCKET_MODE` | `0770` | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets. |
|
||||
| `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.md). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. |
|
||||
| `SERVE_ROBOTS_TXT` | `false` | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. |
|
||||
| `TARGET` | `http://localhost:3923` | The URL of the service that Anubis should forward valid requests to. Supports Unix domain sockets, set this to a URI like so: `unix:///path/to/socket.sock`. |
|
||||
|
||||
### Key generation
|
||||
|
||||
To generate an ed25519 private key, you can use this command:
|
||||
|
||||
```text
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Alternatively here is a key generated by your browser:
|
||||
|
||||
<RandomKey />
|
||||
|
||||
## Docker compose
|
||||
|
||||
@@ -47,13 +82,15 @@ services:
|
||||
METRICS_BIND: ":9090"
|
||||
SERVE_ROBOTS_TXT: "true"
|
||||
TARGET: "http://nginx"
|
||||
POLICY_FNAME: "/data/cfg/botPolicy.json"
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- "./botPolicy.json:/data/cfg/botPolicy.json:ro"
|
||||
nginx:
|
||||
image: nginx
|
||||
volumes:
|
||||
- "./www:/usr/share/nginx/html"
|
||||
- "./botPolicy.json:/data/cfg/botPolicy.json"
|
||||
```
|
||||
|
||||
## Kubernetes
|
||||
|
||||
@@ -52,7 +52,7 @@ Here is a minimal policy file that will protect against most scraper bots:
|
||||
}
|
||||
```
|
||||
|
||||
This allows requests to [`/.well-known`](https://en.wikipedia.org/wiki/Well-known_URI), `/favicon.ico`, `/robots.txt`, and challenges any request that has the word `Mozilla` in its User-Agent string. The [default policy file](https://github.com/TecharoHQ/anubis/blob/main/cmd/anubis/botPolicies.json) is a bit more cohesive, but this should be more than enough for most users.
|
||||
This allows requests to [`/.well-known`](https://en.wikipedia.org/wiki/Well-known_URI), `/favicon.ico`, `/robots.txt`, and challenges any request that has the word `Mozilla` in its User-Agent string. The [default policy file](https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.json) is a bit more cohesive, but this should be more than enough for most users.
|
||||
|
||||
If no rules match the request, it is allowed through.
|
||||
|
||||
|
||||
@@ -23,6 +23,6 @@ Anubis is a bit of a nuclear response. This will result in your website being bl
|
||||
|
||||
## Support
|
||||
|
||||
If you run into any issues running Anubis, please [open an issue](https://github.com/TecharoHQ/anubis/issues/new?template=Blank+issue) and tag it with the Anubis tag. Please include all the information I would need to diagnose your issue.
|
||||
If you run into any issues running Anubis, please [open an issue](https://github.com/TecharoHQ/anubis/issues/new?template=Blank+issue) and include all the information I would need to diagnose your issue.
|
||||
|
||||
For live chat, please join the [Patreon](https://patreon.com/cadey) and ask in the Patron discord in the channel `#anubis`.
|
||||
|
||||
@@ -76,7 +76,7 @@ const config: Config = {
|
||||
title: 'Anubis',
|
||||
logo: {
|
||||
alt: 'A happy jackal woman with brown hair and red eyes',
|
||||
src: 'img/happy.webp',
|
||||
src: 'img/favicon.webp',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- name: anubis
|
||||
image: ghcr.io/techarohq/anubis:latest
|
||||
image: ghcr.io/techarohq/anubis:main
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: "BIND"
|
||||
|
||||
42
docs/src/components/RandomKey/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import Code from "@theme/CodeInline";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
|
||||
// https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/
|
||||
function toHex(buffer) {
|
||||
return Array.prototype.map
|
||||
.call(buffer, (x) => ("00" + x.toString(16)).slice(-2))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export const genRandomKey = (): String => {
|
||||
const array = new Uint8Array(32);
|
||||
self.crypto.getRandomValues(array);
|
||||
return toHex(array);
|
||||
};
|
||||
|
||||
export default function RandomKey() {
|
||||
return (
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
const [key, setKey] = useState<String>(genRandomKey());
|
||||
const genRandomKeyCb = useCallback(() => {
|
||||
setKey(genRandomKey());
|
||||
});
|
||||
return (
|
||||
<span>
|
||||
<Code>{key}</Code>
|
||||
<span style={{ marginLeft: "0.25rem", marginRight: "0.25rem" }} />
|
||||
<button
|
||||
onClick={() => {
|
||||
genRandomKeyCb();
|
||||
}}
|
||||
>
|
||||
♻️
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</BrowserOnly>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
--code-block-diff-add-line-color: #ccffd8;
|
||||
--code-block-diff-remove-line-color: #ffebe9;
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
@@ -27,10 +29,12 @@
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
--code-block-diff-add-line-color: #216932;
|
||||
--code-block-diff-remove-line-color: #8b423b;
|
||||
}
|
||||
|
||||
.code-block-diff-add-line {
|
||||
background-color: #ccffd8;
|
||||
background-color: var(--code-block-diff-add-line-color);
|
||||
display: block;
|
||||
margin: 0 -40px;
|
||||
padding: 0 40px;
|
||||
@@ -44,7 +48,7 @@
|
||||
}
|
||||
|
||||
.code-block-diff-remove-line {
|
||||
background-color: #ffebe9;
|
||||
background-color: var(--code-block-diff-remove-line-color);
|
||||
display: block;
|
||||
margin: 0 -40px;
|
||||
padding: 0 40px;
|
||||
|
||||
BIN
docs/static/img/android-chrome-512x512.png
vendored
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 106 KiB |
BIN
docs/static/img/favicon.ico
vendored
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/favicon.webp
vendored
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
docs/static/img/happy.webp
vendored
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 30 KiB |
8
go.mod
@@ -5,8 +5,10 @@ go 1.24.1
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/playwright-community/playwright-go v0.5001.0
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
)
|
||||
|
||||
@@ -20,11 +22,14 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -34,7 +39,6 @@ require (
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
|
||||
17
go.sum
@@ -19,6 +19,8 @@ github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEf
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc=
|
||||
@@ -31,8 +33,13 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
|
||||
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
@@ -44,10 +51,14 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/playwright-community/playwright-go v0.5001.0 h1:EY3oB+rU9cUp6CLHguWE8VMZTwAg+83Yyb7dQqEmGLg=
|
||||
github.com/playwright-community/playwright-go v0.5001.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -63,6 +74,7 @@ github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+
|
||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
@@ -147,5 +159,6 @@ google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNI
|
||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
12
internal/hash.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func SHA256sum(text string) string {
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(text))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
439
internal/test/playwright_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Integration tests for Anubis, using Playwright.
|
||||
//
|
||||
// These tests require an already running Anubis and Playwright server.
|
||||
//
|
||||
// Anubis must be configured to redirect to the server started by the test suite.
|
||||
// The bind address and the Anubis server can be specified using the flags `-bind` and `-anubis` respectively.
|
||||
//
|
||||
// Playwright must be started in server mode using `npx playwright@1.50.1 run-server --port 3000`.
|
||||
// The version must match the minor used by the playwright-go package.
|
||||
//
|
||||
// On unsupported systems you may be able to use a container instead: https://playwright.dev/docs/docker#remote-connection
|
||||
//
|
||||
// In that case you may need to set the `-playwright` flag to the container's URL, and specify the `--host` the run-server command listens on.
|
||||
package test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
libanubis "github.com/TecharoHQ/anubis/lib"
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
var (
|
||||
serverBindAddr = flag.String("bind", "localhost:3923", "test server bind address")
|
||||
playwrightPort = flag.Int("playwright-port", 3000, "Playwright port")
|
||||
playwrightServer = flag.String("playwright", "ws://localhost:3000", "Playwright server URL")
|
||||
playwrightMaxTime = flag.Duration("playwright-max-time", 5*time.Second, "maximum time for Playwright requests")
|
||||
playwrightMaxHardTime = flag.Duration("playwright-max-hard-time", 5*time.Minute, "maximum time for hard Playwright requests")
|
||||
|
||||
testCases = []testCase{
|
||||
{
|
||||
name: "firefox",
|
||||
action: actionChallenge,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0",
|
||||
},
|
||||
{
|
||||
name: "headlessChrome",
|
||||
action: actionDeny,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/120.0.6099.28 Safari/537.36",
|
||||
},
|
||||
{
|
||||
name: "kagiBadIP",
|
||||
action: actionChallenge,
|
||||
isHard: true,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)",
|
||||
},
|
||||
{
|
||||
name: "kagiGoodIP",
|
||||
action: actionAllow,
|
||||
realIP: "216.18.205.234",
|
||||
userAgent: "Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)",
|
||||
},
|
||||
{
|
||||
name: "unknownAgent",
|
||||
action: actionAllow,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "AnubisTest/0",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
actionAllow action = "ALLOW"
|
||||
actionDeny action = "DENY"
|
||||
actionChallenge action = "CHALLENGE"
|
||||
|
||||
placeholderIP = "fd11:5ee:bad:c0de::"
|
||||
playwrightVersion = "1.50.1"
|
||||
)
|
||||
|
||||
type action string
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
action action
|
||||
isHard bool
|
||||
realIP, userAgent string
|
||||
}
|
||||
|
||||
func doesNPXExist(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH, skipping integration smoke testing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(t *testing.T, command string) string {
|
||||
t.Helper()
|
||||
|
||||
shPath, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] %v", err)
|
||||
}
|
||||
|
||||
t.Logf("running command: %s", command)
|
||||
|
||||
cmd := exec.Command(shPath, "-c", command)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stderr = os.Stderr
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("can't run command: %v", err)
|
||||
}
|
||||
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func daemonize(t *testing.T, command string) {
|
||||
t.Helper()
|
||||
|
||||
shPath, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] %v", err)
|
||||
}
|
||||
|
||||
t.Logf("daemonizing command: %s", command)
|
||||
|
||||
cmd := exec.Command(shPath, "-c", command)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("can't daemonize command: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
cmd.Process.Kill()
|
||||
})
|
||||
}
|
||||
|
||||
func startPlaywright(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if os.Getenv("CI") == "true" {
|
||||
run(t, fmt.Sprintf("npx --yes playwright@%s install --with-deps", playwrightVersion))
|
||||
} else {
|
||||
run(t, fmt.Sprintf("npx --yes playwright@%s install", playwrightVersion))
|
||||
}
|
||||
|
||||
daemonize(t, fmt.Sprintf("npx --yes playwright@%s run-server --port %d", playwrightVersion, *playwrightPort))
|
||||
|
||||
for {
|
||||
if _, err := http.Get(fmt.Sprintf("http://localhost:%d", *playwrightPort)); err != nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
//nosleep:bypass XXX(Xe): Playwright doesn't have a good way to signal readiness. This is a HACK that will just let the tests pass.
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
func TestPlaywrightBrowser(t *testing.T) {
|
||||
if os.Getenv("DONT_USE_NETWORK") != "" {
|
||||
t.Skip("test requires network egress")
|
||||
return
|
||||
}
|
||||
|
||||
doesNPXExist(t)
|
||||
startPlaywright(t)
|
||||
|
||||
pw := setupPlaywright(t)
|
||||
anubisURL := spawnAnubis(t)
|
||||
|
||||
browsers := []playwright.BrowserType{pw.Chromium, pw.Firefox, pw.WebKit}
|
||||
|
||||
for _, typ := range browsers {
|
||||
t.Run(typ.Name()+"/warmup", func(t *testing.T) {
|
||||
browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
|
||||
ExposeNetwork: playwright.String("<loopback>"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not connect to remote browser: %v", err)
|
||||
}
|
||||
defer browser.Close()
|
||||
|
||||
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
AcceptDownloads: playwright.Bool(false),
|
||||
ExtraHttpHeaders: map[string]string{
|
||||
"X-Real-Ip": "127.0.0.1",
|
||||
},
|
||||
UserAgent: playwright.String("Sephiroth"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not create context: %v", err)
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
page, err := ctx.NewPage()
|
||||
if err != nil {
|
||||
t.Fatalf("could not create page: %v", err)
|
||||
}
|
||||
defer page.Close()
|
||||
|
||||
timeout := 2.0
|
||||
page.Goto(anubisURL, playwright.PageGotoOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
})
|
||||
|
||||
for _, tc := range testCases {
|
||||
name := fmt.Sprintf("%s/%s", typ.Name(), tc.name)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, hasDeadline := t.Deadline()
|
||||
if tc.isHard && hasDeadline {
|
||||
t.Skip("skipping hard challenge with deadline")
|
||||
}
|
||||
|
||||
var perfomedAction action
|
||||
var err error
|
||||
for i := 0; i < 5; i++ {
|
||||
perfomedAction, err = executeTestCase(t, tc, typ, anubisURL)
|
||||
if perfomedAction == tc.action {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(i+1) * 250 * time.Millisecond)
|
||||
}
|
||||
if perfomedAction != tc.action {
|
||||
t.Errorf("unexpected test result, expected %s, got %s", tc.action, perfomedAction)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("test error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildBrowserConnect(name string) string {
|
||||
u, _ := url.Parse(*playwrightServer)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("browser", name)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anubisURL string) (action, error) {
|
||||
deadline, _ := t.Deadline()
|
||||
|
||||
browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
|
||||
ExposeNetwork: playwright.String("<loopback>"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not connect to remote browser: %w", err)
|
||||
}
|
||||
defer browser.Close()
|
||||
|
||||
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
AcceptDownloads: playwright.Bool(false),
|
||||
ExtraHttpHeaders: map[string]string{
|
||||
"X-Real-Ip": tc.realIP,
|
||||
},
|
||||
UserAgent: playwright.String(tc.userAgent),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not create context: %w", err)
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
page, err := ctx.NewPage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not create page: %w", err)
|
||||
}
|
||||
defer page.Close()
|
||||
|
||||
// Attempt challenge.
|
||||
|
||||
start := time.Now()
|
||||
_, err = page.Goto(anubisURL, playwright.PageGotoOptions{
|
||||
Timeout: pwTimeout(tc, deadline),
|
||||
})
|
||||
if err != nil {
|
||||
return "", pwFail(t, page, "could not navigate to test server: %v", err)
|
||||
}
|
||||
|
||||
hadChallenge := false
|
||||
switch tc.action {
|
||||
case actionChallenge:
|
||||
// FIXME: This could race if challenge is completed too quickly.
|
||||
checkImage(t, tc, deadline, page, "#image[src*=pensive], #image[src*=happy]")
|
||||
hadChallenge = true
|
||||
case actionDeny:
|
||||
checkImage(t, tc, deadline, page, "#image[src*=sad]")
|
||||
return actionDeny, nil
|
||||
}
|
||||
|
||||
// Ensure protected resource was provided.
|
||||
|
||||
res, err := page.Locator("#anubis-test").TextContent(playwright.LocatorTextContentOptions{
|
||||
Timeout: pwTimeout(tc, deadline),
|
||||
})
|
||||
end := time.Now()
|
||||
if err != nil {
|
||||
pwFail(t, page, "could not get text content: %v", err)
|
||||
}
|
||||
|
||||
var tm int64
|
||||
if _, err := fmt.Sscanf(res, "%d", &tm); err != nil {
|
||||
pwFail(t, page, "unexpected output: %s", res)
|
||||
}
|
||||
|
||||
if tm < start.Unix() || end.Unix() < tm {
|
||||
pwFail(t, page, "unexpected timestamp in output: %d not in range %d..%d", tm, start.Unix(), end.Unix())
|
||||
}
|
||||
|
||||
if hadChallenge {
|
||||
return actionChallenge, nil
|
||||
} else {
|
||||
return actionAllow, nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkImage(t *testing.T, tc testCase, deadline time.Time, page playwright.Page, locator string) {
|
||||
image := page.Locator(locator)
|
||||
err := image.WaitFor(playwright.LocatorWaitForOptions{
|
||||
Timeout: pwTimeout(tc, deadline),
|
||||
})
|
||||
if err != nil {
|
||||
pwFail(t, page, "could not wait for result: %v", err)
|
||||
}
|
||||
|
||||
failIsVisible, err := image.IsVisible()
|
||||
if err != nil {
|
||||
pwFail(t, page, "could not check result image: %v", err)
|
||||
}
|
||||
|
||||
if !failIsVisible {
|
||||
pwFail(t, page, "expected result image not visible")
|
||||
}
|
||||
}
|
||||
|
||||
func pwFail(t *testing.T, page playwright.Page, format string, args ...any) error {
|
||||
t.Helper()
|
||||
|
||||
saveScreenshot(t, page)
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func pwTimeout(tc testCase, deadline time.Time) *float64 {
|
||||
max := *playwrightMaxTime
|
||||
if tc.isHard {
|
||||
max = *playwrightMaxHardTime
|
||||
}
|
||||
|
||||
d := time.Until(deadline)
|
||||
if d <= 0 || d > max {
|
||||
return playwright.Float(float64(max.Milliseconds()))
|
||||
}
|
||||
return playwright.Float(float64(d.Milliseconds()))
|
||||
}
|
||||
|
||||
func saveScreenshot(t *testing.T, page playwright.Page) {
|
||||
t.Helper()
|
||||
|
||||
data, err := page.Screenshot()
|
||||
if err != nil {
|
||||
t.Logf("could not take screenshot: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "anubis-test-fail-*.png")
|
||||
if err != nil {
|
||||
t.Logf("could not create temporary file: %v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(data)
|
||||
if err != nil {
|
||||
t.Logf("could not write screenshot: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("screenshot saved to %s", f.Name())
|
||||
}
|
||||
|
||||
func setupPlaywright(t *testing.T) *playwright.Playwright {
|
||||
err := playwright.Install(&playwright.RunOptions{
|
||||
SkipInstallBrowsers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not install Playwright: %v", err)
|
||||
}
|
||||
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("could not start Playwright: %v", err)
|
||||
}
|
||||
return pw
|
||||
}
|
||||
|
||||
func spawnAnubis(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
|
||||
})
|
||||
|
||||
policy, err := libanubis.LoadPoliciesOrDefault("", anubis.DefaultDifficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := libanubis.New(libanubis.Options{
|
||||
Next: h,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(s)
|
||||
t.Log(ts.URL)
|
||||
|
||||
t.Cleanup(func() {
|
||||
ts.Close()
|
||||
})
|
||||
|
||||
return ts.URL
|
||||
}
|
||||
3
internal/test/var/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.png
|
||||
*.txt
|
||||
*.html
|
||||
527
lib/anubis.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"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/lib/policy"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/TecharoHQ/anubis/web"
|
||||
"github.com/TecharoHQ/anubis/xess"
|
||||
)
|
||||
|
||||
var (
|
||||
challengesIssued = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_issued",
|
||||
Help: "The total number of challenges issued",
|
||||
})
|
||||
|
||||
challengesValidated = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "anubis_challenges_validated",
|
||||
Help: "The total number of challenges validated",
|
||||
})
|
||||
|
||||
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_dronebl_hits",
|
||||
Help: "The total number of hits from DroneBL",
|
||||
}, []string{"status"})
|
||||
|
||||
failedValidations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "anubis_failed_validations",
|
||||
Help: "The total number of failed validations",
|
||||
})
|
||||
|
||||
timeTaken = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "anubis_time_taken",
|
||||
Help: "The time taken for a browser to generate a response (milliseconds)",
|
||||
Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 18), 19),
|
||||
})
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Next http.Handler
|
||||
Policy *policy.ParsedConfig
|
||||
ServeRobotsTXT bool
|
||||
PrivateKey ed25519.PrivateKey
|
||||
|
||||
CookieDomain string
|
||||
CookieName string
|
||||
CookiePartitioned bool
|
||||
}
|
||||
|
||||
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.json"
|
||||
fin, err = data.BotPolicies.Open("botPolicies.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer fin.Close()
|
||||
|
||||
policy, err := policy.ParseConfig(fin, fname, defaultDifficulty)
|
||||
|
||||
return policy, 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
|
||||
}
|
||||
|
||||
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](),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
|
||||
mux.Handle(anubis.StaticPath, internal.UnchangingCache(http.StripPrefix(anubis.StaticPath, http.FileServerFS(web.Static))))
|
||||
|
||||
if opts.ServeRobotsTXT {
|
||||
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/.well-known/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
})
|
||||
}
|
||||
|
||||
// mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding)
|
||||
|
||||
mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", result.MakeChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", result.PassChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", result.TestError)
|
||||
|
||||
mux.HandleFunc("/", result.MaybeReverseProxy)
|
||||
|
||||
result.mux = mux
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
next http.Handler
|
||||
priv ed25519.PrivateKey
|
||||
pub ed25519.PublicKey
|
||||
policy *policy.ParsedConfig
|
||||
opts Options
|
||||
DNSBLCache *decaymap.Impl[string, dnsbl.DroneBLResponse]
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
|
||||
fp := sha256.Sum256(s.priv.Seed())
|
||||
|
||||
data := fmt.Sprintf(
|
||||
"Accept-Language=%s,X-Real-IP=%s,User-Agent=%s,WeekTime=%s,Fingerprint=%x,Difficulty=%d",
|
||||
r.Header.Get("Accept-Language"),
|
||||
r.Header.Get("X-Real-Ip"),
|
||||
r.UserAgent(),
|
||||
time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339),
|
||||
fp,
|
||||
difficulty,
|
||||
)
|
||||
return internal.SHA256sum(data)
|
||||
}
|
||||
|
||||
func (s *Server) MaybeReverseProxy(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"),
|
||||
)
|
||||
|
||||
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\"")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Add("X-Anubis-Rule", cr.Name)
|
||||
r.Header.Add("X-Anubis-Action", string(cr.Rule))
|
||||
lg = lg.With("check_result", cr)
|
||||
policy.PolicyApplications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
||||
|
||||
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))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch cr.Rule {
|
||||
case config.RuleAllow:
|
||||
lg.Debug("allowing traffic to origin (explicit)")
|
||||
s.next.ServeHTTP(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)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
hash, err := rule.Hash()
|
||||
if err != nil {
|
||||
lg.Error("can't calculate checksum of rule", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg.Debug("rule hash", "hash", hash)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("Access Denied: error code %s", hash))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleChallenge:
|
||||
lg.Debug("challenge requested")
|
||||
default:
|
||||
s.ClearCookie(w)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ckie, err := r.Cookie(anubis.CookieName)
|
||||
if err != nil {
|
||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ckie.Valid(); err != nil {
|
||||
lg.Debug("cookie is invalid", "err", err)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return s.pub, nil
|
||||
}, jwt.WithExpirationRequired(), jwt.WithStrictDecoding())
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if randomJitter() {
|
||||
r.Header.Add("X-Anubis-Status", "PASS-BRIEF")
|
||||
lg.Debug("cookie is not enrolled into secondary screening")
|
||||
s.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
lg.Debug("invalid token claims type", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
if claims["challenge"] != challenge {
|
||||
lg.Debug("invalid challenge", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var nonce int
|
||||
|
||||
if v, ok := claims["nonce"].(float64); ok {
|
||||
nonce = int(v)
|
||||
}
|
||||
|
||||
calcString := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 {
|
||||
lg.Debug("invalid response", "path", r.URL.Path)
|
||||
failedValidations.Inc()
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("all checks passed")
|
||||
r.Header.Add("X-Anubis-Status", "PASS-FULL")
|
||||
s.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request) {
|
||||
templ.Handler(
|
||||
web.Base("Making sure you're not a bot!", web.Index()),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Error string `json:"error"`
|
||||
}{
|
||||
Error: "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"makeChallenge\"",
|
||||
})
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr)
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Rules *config.ChallengeRules `json:"rules"`
|
||||
}{
|
||||
Challenge: challenge,
|
||||
Rules: rule.Challenge,
|
||||
})
|
||||
lg.Debug("made challenge", "challenge", challenge, "rules", rule.Challenge, "cr", cr)
|
||||
challengesIssued.Inc()
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
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\".")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr)
|
||||
|
||||
nonceStr := r.FormValue("nonce")
|
||||
if nonceStr == "" {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("no nonce")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTimeStr := r.FormValue("elapsedTime")
|
||||
if elapsedTimeStr == "" {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("no elapsedTime")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("elapsedTime doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
lg.Info("challenge took", "elapsedTime", elapsedTime)
|
||||
timeTaken.Observe(elapsedTime)
|
||||
|
||||
response := r.FormValue("response")
|
||||
redir := r.FormValue("redir")
|
||||
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
nonce, err := strconv.Atoi(nonceStr)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("nonce doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
calcString := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
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")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
|
||||
failedValidations.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// compare the leading zeroes
|
||||
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")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
|
||||
failedValidations.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// generate JWT cookie
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
|
||||
"challenge": challenge,
|
||||
"nonce": nonce,
|
||||
"response": response,
|
||||
"iat": time.Now().Unix(),
|
||||
"nbf": time.Now().Add(-1 * time.Minute).Unix(),
|
||||
"exp": time.Now().Add(24 * 7 * time.Hour).Unix(),
|
||||
})
|
||||
tokenString, err := token.SignedString(s.priv)
|
||||
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")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: anubis.CookieName,
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(24 * 7 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: s.opts.CookieDomain,
|
||||
Partitioned: s.opts.CookiePartitioned,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
challengesValidated.Inc()
|
||||
lg.Debug("challenge passed, redirecting to app")
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) TestError(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.FormValue("err")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage(err)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Check evaluates the list of rules, and returns the result
|
||||
func (s *Server) check(r *http.Request) (CheckResult, *policy.Bot, error) {
|
||||
host := r.Header.Get("X-Real-Ip")
|
||||
if host == "" {
|
||||
return decaymap.Zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
|
||||
}
|
||||
|
||||
addr := net.ParseIP(host)
|
||||
if addr == nil {
|
||||
return decaymap.Zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
|
||||
}
|
||||
|
||||
for _, b := range s.policy.Bots {
|
||||
if b.UserAgent != nil {
|
||||
if b.UserAgent.MatchString(r.UserAgent()) && s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Path != nil {
|
||||
if b.Path.MatchString(r.URL.Path) && s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Ranger != nil {
|
||||
if s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cr("default/allow", config.RuleAllow), &policy.Bot{
|
||||
Challenge: &config.ChallengeRules{
|
||||
Difficulty: s.policy.DefaultDifficulty,
|
||||
ReportAs: s.policy.DefaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) checkRemoteAddress(b policy.Bot, addr net.IP) bool {
|
||||
if b.Ranger == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
ok, err := b.Ranger.Contains(addr)
|
||||
if err != nil {
|
||||
log.Panicf("[unexpected] something very funky is going on, %q does not have a calculable network number: %v", addr.String(), err)
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
163
lib/anubis_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/lib/policy"
|
||||
)
|
||||
|
||||
func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
|
||||
t.Helper()
|
||||
|
||||
policy, err := LoadPoliciesOrDefault("", anubis.DefaultDifficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
func spawnAnubis(t *testing.T, opts Options) *Server {
|
||||
t.Helper()
|
||||
|
||||
s, err := New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCookieSettings(t *testing.T) {
|
||||
pol := loadPolicies(t, "")
|
||||
pol.DefaultDifficulty = 0
|
||||
|
||||
srv := spawnAnubis(t, Options{
|
||||
Next: http.NewServeMux(),
|
||||
Policy: pol,
|
||||
|
||||
CookieDomain: "local.cetacean.club",
|
||||
CookiePartitioned: true,
|
||||
CookieName: t.Name(),
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(internal.DefaultXRealIP("127.0.0.1", srv))
|
||||
defer ts.Close()
|
||||
|
||||
cli := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't request challenge: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var chall = struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
|
||||
t.Fatalf("can't read challenge response body: %v", err)
|
||||
}
|
||||
|
||||
nonce := 0
|
||||
elapsedTime := 420
|
||||
redir := "/"
|
||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't make request: %v", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("response", calculated)
|
||||
q.Set("nonce", fmt.Sprint(nonce))
|
||||
q.Set("redir", redir)
|
||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err = cli.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("can't do challenge passing")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
var ckie *http.Cookie
|
||||
for _, cookie := range resp.Cookies() {
|
||||
t.Logf("%#v", cookie)
|
||||
if cookie.Name == anubis.CookieName {
|
||||
ckie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ckie.Domain != "local.cetacean.club" {
|
||||
t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
|
||||
}
|
||||
|
||||
if ckie.Partitioned != srv.opts.CookiePartitioned {
|
||||
t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
|
||||
}
|
||||
|
||||
if ckie == nil {
|
||||
t.Errorf("Cookie %q not found", anubis.CookieName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "OK")
|
||||
})
|
||||
|
||||
for i := 1; i < 10; i++ {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
policy, err := LoadPoliciesOrDefault("", i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(Options{
|
||||
Next: h,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req.Header.Add("X-Real-Ip", "127.0.0.1")
|
||||
|
||||
_, bot, err := s.check(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if bot.Challenge.Difficulty != i {
|
||||
t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
|
||||
}
|
||||
|
||||
if bot.Challenge.ReportAs != i {
|
||||
t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
25
lib/checkresult.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
)
|
||||
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Rule config.Rule
|
||||
}
|
||||
|
||||
func (cr CheckResult) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.String("name", cr.Name),
|
||||
slog.String("rule", string(cr.Rule)))
|
||||
}
|
||||
|
||||
func cr(name string, rule config.Rule) CheckResult {
|
||||
return CheckResult{
|
||||
Name: name,
|
||||
Rule: rule,
|
||||
}
|
||||
}
|
||||
35
lib/http.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
)
|
||||
|
||||
func (s *Server) ClearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: anubis.CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: s.opts.CookieDomain,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
|
||||
type UnixRoundTripper struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// set bare minimum stuff
|
||||
func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Host == "" {
|
||||
req.Host = "localhost"
|
||||
}
|
||||
req.URL.Host = req.Host // proxy error: no Host in request URL
|
||||
req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
|
||||
return t.Transport.RoundTrip(req)
|
||||
}
|
||||
32
lib/policy/bot.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/yl2chen/cidranger"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
Name string
|
||||
UserAgent *regexp.Regexp
|
||||
Path *regexp.Regexp
|
||||
Action config.Rule `json:"action"`
|
||||
Challenge *config.ChallengeRules
|
||||
Ranger cidranger.Ranger
|
||||
}
|
||||
|
||||
func (b Bot) Hash() (string, error) {
|
||||
var pathRex string
|
||||
if b.Path != nil {
|
||||
pathRex = b.Path.String()
|
||||
}
|
||||
var userAgentRex string
|
||||
if b.UserAgent != nil {
|
||||
userAgentRex = b.UserAgent.String()
|
||||
}
|
||||
|
||||
return internal.SHA256sum(fmt.Sprintf("%s::%s::%s", b.Name, pathRex, userAgentRex)), nil
|
||||
}
|
||||
@@ -7,6 +7,17 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule")
|
||||
ErrBotMustHaveName = errors.New("config.Bot: must set name")
|
||||
ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex, or remote_addresses")
|
||||
ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both")
|
||||
ErrUnknownAction = errors.New("config.Bot: unknown action")
|
||||
ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex")
|
||||
ErrInvalidPathRegex = errors.New("config.Bot: invalid path regex")
|
||||
ErrInvalidCIDR = errors.New("config.Bot: invalid CIDR")
|
||||
)
|
||||
|
||||
type Rule string
|
||||
|
||||
const (
|
||||
@@ -24,7 +35,7 @@ const (
|
||||
AlgorithmSlow Algorithm = "slow"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
type BotConfig struct {
|
||||
Name string `json:"name"`
|
||||
UserAgentRegex *string `json:"user_agent_regex"`
|
||||
PathRegex *string `json:"path_regex"`
|
||||
@@ -33,25 +44,14 @@ type Bot struct {
|
||||
Challenge *ChallengeRules `json:"challenge,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule")
|
||||
ErrBotMustHaveName = errors.New("config.Bot: must set name")
|
||||
ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex, or remote_addresses")
|
||||
ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both")
|
||||
ErrUnknownAction = errors.New("config.Bot: unknown action")
|
||||
ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex")
|
||||
ErrInvalidPathRegex = errors.New("config.Bot: invalid path regex")
|
||||
ErrInvalidCIDR = errors.New("config.Bot: invalid CIDR")
|
||||
)
|
||||
|
||||
func (b Bot) Valid() error {
|
||||
func (b BotConfig) Valid() error {
|
||||
var errs []error
|
||||
|
||||
if b.Name == "" {
|
||||
errs = append(errs, ErrBotMustHaveName)
|
||||
}
|
||||
|
||||
if b.UserAgentRegex == nil && b.PathRegex == nil && (b.RemoteAddr == nil || len(b.RemoteAddr) == 0) {
|
||||
if b.UserAgentRegex == nil && b.PathRegex == nil && len(b.RemoteAddr) == 0 {
|
||||
errs = append(errs, ErrBotMustHaveUserAgentOrPath)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (b Bot) Valid() error {
|
||||
}
|
||||
}
|
||||
|
||||
if b.RemoteAddr != nil && len(b.RemoteAddr) > 0 {
|
||||
if len(b.RemoteAddr) > 0 {
|
||||
for _, cidr := range b.RemoteAddr {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
errs = append(errs, ErrInvalidCIDR, err)
|
||||
@@ -137,8 +137,8 @@ func (cr ChallengeRules) Valid() error {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Bots []Bot `json:"bots"`
|
||||
DNSBL bool `json:"dnsbl"`
|
||||
Bots []BotConfig `json:"bots"`
|
||||
DNSBL bool `json:"dnsbl"`
|
||||
}
|
||||
|
||||
func (c Config) Valid() error {
|
||||
@@ -13,12 +13,12 @@ func p[V any](v V) *V { return &v }
|
||||
func TestBotValid(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
bot Bot
|
||||
bot BotConfig
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "simple user agent",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -27,7 +27,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "simple path",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "well-known-path",
|
||||
Action: RuleAllow,
|
||||
PathRegex: p("^/.well-known/.*$"),
|
||||
@@ -36,7 +36,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no rule name",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Action: RuleChallenge,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
},
|
||||
@@ -44,7 +44,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no rule matcher",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "broken-rule",
|
||||
Action: RuleAllow,
|
||||
},
|
||||
@@ -52,7 +52,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "both user-agent and path",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "path-and-user-agent",
|
||||
Action: RuleDeny,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -62,7 +62,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "Unknown action",
|
||||
Action: RuleUnknown,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -71,7 +71,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid user agent regex",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
UserAgentRegex: p("a(b"),
|
||||
@@ -80,7 +80,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid path regex",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("a(b"),
|
||||
@@ -89,7 +89,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "challenge difficulty too low",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("Mozilla"),
|
||||
@@ -103,7 +103,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "challenge difficulty too high",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("Mozilla"),
|
||||
@@ -117,7 +117,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "challenge wrong algorithm",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("Mozilla"),
|
||||
@@ -131,7 +131,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid cidr range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
RemoteAddr: []string{"0.0.0.0/33"},
|
||||
@@ -140,7 +140,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "only filter by IP range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
RemoteAddr: []string{"0.0.0.0/0"},
|
||||
@@ -149,7 +149,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "filter by user agent and IP range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -159,7 +159,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "filter by path and IP range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
PathRegex: p("^.*$"),
|
||||
121
lib/policy/policy.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/yl2chen/cidranger"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
)
|
||||
|
||||
var (
|
||||
PolicyApplications = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_policy_results",
|
||||
Help: "The results of each policy rule",
|
||||
}, []string{"rule", "action"})
|
||||
)
|
||||
|
||||
type ParsedConfig struct {
|
||||
orig config.Config
|
||||
|
||||
Bots []Bot
|
||||
DNSBL bool
|
||||
DefaultDifficulty int
|
||||
}
|
||||
|
||||
func NewParsedConfig(orig config.Config) *ParsedConfig {
|
||||
return &ParsedConfig{
|
||||
orig: orig,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
|
||||
var c config.Config
|
||||
if err := json.NewDecoder(fin).Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy config JSON %s: %w", fname, err)
|
||||
}
|
||||
|
||||
if err := c.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var validationErrs []error
|
||||
|
||||
result := NewParsedConfig(c)
|
||||
result.DefaultDifficulty = defaultDifficulty
|
||||
|
||||
for _, b := range c.Bots {
|
||||
if berr := b.Valid(); berr != nil {
|
||||
validationErrs = append(validationErrs, berr)
|
||||
continue
|
||||
}
|
||||
|
||||
parsedBot := Bot{
|
||||
Name: b.Name,
|
||||
Action: b.Action,
|
||||
}
|
||||
|
||||
if len(b.RemoteAddr) > 0 {
|
||||
parsedBot.Ranger = cidranger.NewPCTrieRanger()
|
||||
|
||||
for _, cidr := range b.RemoteAddr {
|
||||
_, rng, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] range %s not parsing: %w", cidr, err)
|
||||
}
|
||||
|
||||
parsedBot.Ranger.Insert(cidranger.NewBasicRangerEntry(*rng))
|
||||
}
|
||||
}
|
||||
|
||||
if b.UserAgentRegex != nil {
|
||||
userAgent, err := regexp.Compile(*b.UserAgentRegex)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while compiling user agent regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.UserAgent = userAgent
|
||||
}
|
||||
}
|
||||
|
||||
if b.PathRegex != nil {
|
||||
path, err := regexp.Compile(*b.PathRegex)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while compiling path regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.Path = path
|
||||
}
|
||||
}
|
||||
|
||||
if b.Challenge == nil {
|
||||
parsedBot.Challenge = &config.ChallengeRules{
|
||||
Difficulty: defaultDifficulty,
|
||||
ReportAs: defaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
}
|
||||
} else {
|
||||
parsedBot.Challenge = b.Challenge
|
||||
if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown {
|
||||
parsedBot.Challenge.Algorithm = config.AlgorithmFast
|
||||
}
|
||||
}
|
||||
|
||||
result.Bots = append(result.Bots, parsedBot)
|
||||
}
|
||||
|
||||
if len(validationErrs) > 0 {
|
||||
return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, errors.Join(validationErrs...))
|
||||
}
|
||||
|
||||
result.DNSBL = c.DNSBL
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
package main
|
||||
package policy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/data"
|
||||
)
|
||||
|
||||
func TestDefaultPolicyMustParse(t *testing.T) {
|
||||
fin, err := static.Open("botPolicies.json")
|
||||
fin, err := data.BotPolicies.Open("botPolicies.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := parseConfig(fin, "botPolicies.json", defaultDifficulty); err != nil {
|
||||
if _, err := ParseConfig(fin, "botPolicies.json", anubis.DefaultDifficulty); err != nil {
|
||||
t.Fatalf("can't parse config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoodConfigs(t *testing.T) {
|
||||
finfos, err := os.ReadDir("internal/config/testdata/good")
|
||||
finfos, err := os.ReadDir("config/testdata/good")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -27,13 +30,13 @@ func TestGoodConfigs(t *testing.T) {
|
||||
for _, st := range finfos {
|
||||
st := st
|
||||
t.Run(st.Name(), func(t *testing.T) {
|
||||
fin, err := os.Open(filepath.Join("internal", "config", "testdata", "good", st.Name()))
|
||||
fin, err := os.Open(filepath.Join("config", "testdata", "good", st.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := parseConfig(fin, fin.Name(), defaultDifficulty); err != nil {
|
||||
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -41,7 +44,7 @@ func TestGoodConfigs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBadConfigs(t *testing.T) {
|
||||
finfos, err := os.ReadDir("internal/config/testdata/bad")
|
||||
finfos, err := os.ReadDir("config/testdata/bad")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,13 +52,13 @@ func TestBadConfigs(t *testing.T) {
|
||||
for _, st := range finfos {
|
||||
st := st
|
||||
t.Run(st.Name(), func(t *testing.T) {
|
||||
fin, err := os.Open(filepath.Join("internal", "config", "testdata", "bad", st.Name()))
|
||||
fin, err := os.Open(filepath.Join("config", "testdata", "bad", st.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := parseConfig(fin, fin.Name(), defaultDifficulty); err == nil {
|
||||
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
t.Log(err)
|
||||
9
lib/random.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func randomJitter() bool {
|
||||
return rand.Intn(100) > 10
|
||||
}
|
||||
14
web/embed.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:generate go tool github.com/a-h/templ/cmd/templ generate
|
||||
//go:generate esbuild js/main.mjs --sourcemap --bundle --minify --outfile=static/js/main.mjs
|
||||
//go:generate gzip -f -k static/js/main.mjs
|
||||
//go:generate zstd -f -k --ultra -22 static/js/main.mjs
|
||||
//go:generate brotli -fZk static/js/main.mjs
|
||||
|
||||
var (
|
||||
//go:embed static
|
||||
Static embed.FS
|
||||
)
|
||||
15
web/index.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package web
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
|
||||
func Base(title string, body templ.Component) templ.Component {
|
||||
return base(title, body)
|
||||
}
|
||||
|
||||
func Index() templ.Component {
|
||||
return index()
|
||||
}
|
||||
|
||||
func ErrorPage(msg string) templ.Component {
|
||||
return errorPage(msg)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/TecharoHQ/anubis"
|
||||
@@ -153,6 +153,7 @@ templ base(title string, body templ.Component) {
|
||||
href="https://techaro.lol"
|
||||
>Techaro</a>. Made with ❤️ in 🇨🇦.
|
||||
</p>
|
||||
<p>Mascot design by <a href="https://bsky.app/profile/celphase.bsky.social">CELPHASE</a>.</p>
|
||||
</center>
|
||||
</footer>
|
||||
</main>
|
||||
@@ -208,7 +209,7 @@ templ errorPage(message string) {
|
||||
<img
|
||||
id="image"
|
||||
style="width:100%;max-width:256px;"
|
||||
src={ "/.within.website/x/cmd/anubis/static/img/sad.webp?cacheBuster=" + anubis.Version }
|
||||
src={ "/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version }
|
||||
/>
|
||||
<p>{ message }.</p>
|
||||
<button onClick="window.location.reload();">Try again</button>
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.833
|
||||
package main
|
||||
package web
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -89,7 +89,7 @@ func base(title string, body templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<footer><center><p>Protected by <a href=\"https://github.com/TecharoHQ/anubis\">Anubis</a> from <a href=\"https://techaro.lol\">Techaro</a>. Made with ❤️ in 🇨🇦.</p></center></footer></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<footer><center><p>Protected by <a href=\"https://github.com/TecharoHQ/anubis\">Anubis</a> from <a href=\"https://techaro.lol\">Techaro</a>. Made with ❤️ in 🇨🇦.</p><p>Mascot design by <a href=\"https://bsky.app/profile/celphase.bsky.social\">CELPHASE</a>.</p></center></footer></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func index() templ.Component {
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" +
|
||||
anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 169, Col: 18}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 170, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func index() templ.Component {
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
|
||||
anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 175, Col: 18}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 176, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func index() templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 178, Col: 116}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 179, Col: 116}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -193,9 +193,9 @@ func errorPage(message string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/sad.webp?cacheBuster=" + anubis.Version)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 211, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 212, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -208,7 +208,7 @@ func errorPage(message string) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 213, Col: 14}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 214, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -27,6 +27,22 @@ const imageURL = (mood, cacheBuster) =>
|
||||
const spinner = document.getElementById('spinner');
|
||||
const anubisVersion = JSON.parse(document.getElementById('anubis_version').textContent);
|
||||
|
||||
const ohNoes = ({ titleMsg, statusMsg, imageSrc }) => {
|
||||
title.innerHTML = titleMsg;
|
||||
status.innerHTML = statusMsg;
|
||||
image.src = imageSrc;
|
||||
progress.style.display = "none";
|
||||
};
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
ohNoes({
|
||||
titleMsg: "Your context is not secure!",
|
||||
statusMsg: `Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure">MDN</a>.`,
|
||||
imageSrc: imageURL("reject", anubisVersion),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// const testarea = document.getElementById('testarea');
|
||||
|
||||
// const videoWorks = await testVideo(testarea);
|
||||
@@ -35,9 +51,9 @@ const imageURL = (mood, cacheBuster) =>
|
||||
// if (!videoWorks) {
|
||||
// title.innerHTML = "Oh no!";
|
||||
// status.innerHTML = "Checks failed. Please check your browser's settings and try again.";
|
||||
// image.src = imageURL("sad");
|
||||
// spinner.innerHTML = "";
|
||||
// spinner.style.display = "none";
|
||||
// image.src = imageURL("reject");
|
||||
// return;
|
||||
// }
|
||||
|
||||
@@ -51,21 +67,21 @@ const imageURL = (mood, cacheBuster) =>
|
||||
return r.json();
|
||||
})
|
||||
.catch(err => {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to fetch config: ${err.message}`;
|
||||
image.src = imageURL("sad", anubisVersion);
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
ohNoes({
|
||||
titleMsg: "Internal error!",
|
||||
statusMsg: `Failed to fetch challenge config: ${err.message}`,
|
||||
imageSrc: imageURL("reject", anubisVersion),
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const process = algorithms[rules.algorithm];
|
||||
if (!process) {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to resolve check algorithm. You may want to reload the page.`;
|
||||
image.src = imageURL("sad", anubisVersion);
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
ohNoes({
|
||||
titleMsg: "Challenge error!",
|
||||
statusMsg: `Failed to resolve check algorithm. You may want to reload the page.`,
|
||||
imageSrc: imageURL("reject", anubisVersion),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
BIN
web/static/img/happy.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
web/static/img/pensive.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
web/static/img/reject.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
2
web/static/js/main.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
(()=>{function p(s,r=5,e=navigator.hardwareConcurrency||1){return console.debug("fast algo"),new Promise((t,n)=>{let o=URL.createObjectURL(new Blob(["(",S(),")()"],{type:"application/javascript"})),a=[];for(let i=0;i<e;i++){let c=new Worker(o);c.onmessage=d=>{a.forEach(m=>m.terminate()),c.terminate(),t(d.data)},c.onerror=d=>{c.terminate(),n()},c.postMessage({data:s,difficulty:r,nonce:i,threads:e}),a.push(c)}URL.revokeObjectURL(o)})}function S(){return function(){let s=e=>{let t=new TextEncoder().encode(e);return crypto.subtle.digest("SHA-256",t.buffer)};function r(e){return Array.from(e).map(t=>t.toString(16).padStart(2,"0")).join("")}addEventListener("message",async e=>{let t=e.data.data,n=e.data.difficulty,o,a=e.data.nonce,i=e.data.threads;for(;;){let c=await s(t+a),d=new Uint8Array(c),m=!0;for(let u=0;u<n;u++){let g=Math.floor(u/2),l=u%2;if((d[g]>>(l===0?4:0)&15)!==0){m=!1;break}}if(m){o=r(d),console.log(o);break}a+=i}postMessage({hash:o,data:t,difficulty:n,nonce:a})})}.toString()}function f(s,r=5,e=1){return console.debug("slow algo"),new Promise((t,n)=>{let o=URL.createObjectURL(new Blob(["(",L(),")()"],{type:"application/javascript"})),a=new Worker(o);a.onmessage=i=>{a.terminate(),t(i.data)},a.onerror=i=>{a.terminate(),n()},a.postMessage({data:s,difficulty:r}),URL.revokeObjectURL(o)})}function L(){return function(){let s=r=>{let e=new TextEncoder().encode(r);return crypto.subtle.digest("SHA-256",e.buffer).then(t=>Array.from(new Uint8Array(t)).map(n=>n.toString(16).padStart(2,"0")).join(""))};addEventListener("message",async r=>{let e=r.data.data,t=r.data.difficulty,n,o=0;do n=await s(e+o++);while(n.substring(0,t)!==Array(t+1).join("0"));o-=1,postMessage({hash:n,data:e,difficulty:t,nonce:o})})}.toString()}var T={fast:p,slow:f},y=(s="",r={})=>{let e=new URL(s,window.location.href);return Object.entries(r).forEach(t=>{let[n,o]=t;e.searchParams.set(n,o)}),e.toString()},h=(s,r)=>y(`/.within.website/x/cmd/anubis/static/img/${s}.webp`,{cacheBuster:r});(async()=>{let s=document.getElementById("status"),r=document.getElementById("image"),e=document.getElementById("title"),t=document.getElementById("spinner"),n=JSON.parse(document.getElementById("anubis_version").textContent),o=({titleMsg:l,statusMsg:w,imageSrc:b})=>{e.innerHTML=l,s.innerHTML=w,r.src=b,progress.style.display="none"};if(!window.isSecureContext){o({titleMsg:"Your context is not secure!",statusMsg:'Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure">MDN</a>.',imageSrc:h("reject",n)});return}s.innerHTML="Calculating...";let{challenge:a,rules:i}=await fetch("/.within.website/x/cmd/anubis/api/make-challenge",{method:"POST"}).then(l=>{if(!l.ok)throw new Error("Failed to fetch config");return l.json()}).catch(l=>{throw o({titleMsg:"Internal error!",statusMsg:`Failed to fetch challenge config: ${l.message}`,imageSrc:h("reject",n)}),l}),c=T[i.algorithm];if(!c){o({titleMsg:"Challenge error!",statusMsg:"Failed to resolve check algorithm. You may want to reload the page.",imageSrc:h("reject",n)});return}s.innerHTML=`Calculating...<br/>Difficulty: ${i.report_as}`;let d=Date.now(),{hash:m,nonce:u}=await c(a,i.difficulty),g=Date.now();console.log({hash:m,nonce:u}),e.innerHTML="Success!",s.innerHTML=`Done! Took ${g-d}ms, ${u} iterations`,r.src=h("happy",n),t.innerHTML="",t.style.display="none",setTimeout(()=>{let l=window.location.href;window.location.href=y("/.within.website/x/cmd/anubis/api/pass-challenge",{response:m,nonce:u,redir:l,elapsedTime:g-d})},250)})();})();
|
||||
//# sourceMappingURL=main.mjs.map
|
||||
BIN
web/static/js/main.mjs.br
Normal file
BIN
web/static/js/main.mjs.gz
Normal file
BIN
web/static/js/main.mjs.zst
Normal file
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.833
|
||||
// templ: version: v0.3.857
|
||||
package xess
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||