Compare commits

..

2 Commits

Author SHA1 Message Date
Xe Iaso
c28b191b79 feat: add valkey backed store
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-05-16 06:59:55 -04:00
Xe Iaso
6e964e6449 feat(config): add WEIGH action allowing admins to adjust request weight
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-05-15 08:29:37 -04:00
63 changed files with 546 additions and 1295 deletions

View File

@@ -1,4 +1,3 @@
acs
aeacus
Aibrew
alrest
@@ -7,9 +6,6 @@ anthro
anubis
anubistest
archlinux
asnc
asnchecker
asns
badregexes
berr
bingbot
@@ -22,7 +18,6 @@ botnet
BPort
broked
cachebuster
cachediptoasn
Caddyfile
caninetools
Cardyb
@@ -76,22 +71,15 @@ Fordola
forgejo
fsys
fullchain
gaissmai
Galvus
geoip
geoipchecker
gha
gipc
gitea
godotenv
goland
gomod
goodbot
googlebot
govulncheck
GPG
grpcprom
grw
Hashcash
hashrate
headermap
@@ -99,18 +87,13 @@ healthcheck
hec
hmc
hostable
htmx
httpdebug
hypertext
iat
ifm
inp
IPTo
iptoasn
iss
ivh
JGit
joho
journalctl
jshelter
JWTs
@@ -127,13 +110,11 @@ lgbt
licend
licstart
lightpanda
LIMSA
Linting
linuxbrew
LLU
loadbalancer
lol
LOMINSA
maintainership
malware
mcr
@@ -168,7 +149,6 @@ promauto
promhttp
pwcmd
pwuser
qualys
qwant
qwantbot
rac
@@ -182,15 +162,12 @@ risc
ruleset
RUnlock
sas
sasl
Scumm
searx
sebest
secretplans
selfsigned
setsebool
sitemap
sls
Sourceware
Spambot
sparkline
@@ -203,14 +180,11 @@ subr
subrequest
tagline
tarballs
tarrif
techaro
techarohq
templ
templruntime
testarea
thoth
thothmock
torproject
traefik
unixhttpd
@@ -226,7 +200,7 @@ webmaster
webpage
websecure
websites
Workaround
workaround
workdir
xcaddy
Xeact
@@ -236,7 +210,6 @@ xesite
xess
xff
XForwarded
XNG
XReal
yae
YAMLTo

View File

@@ -3,7 +3,7 @@ name: Docs deploy
on:
workflow_dispatch:
push:
branches: ["main"]
branches: [ "main" ]
permissions:
contents: read
@@ -24,7 +24,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Log into registry
- name: Log into registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
@@ -39,7 +39,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: ./docs
cache-to: type=gha
@@ -50,15 +50,15 @@ jobs:
push: true
- name: Apply k8s manifests to aeacus
uses: actions-hub/kubectl@f632a31512a74cb35940627c49c20f67723cbaaf # v1.33.1
uses: actions-hub/kubectl@e81783053d902f50d752d21a6d99cf9689a652e1 # v1.33.0
env:
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
KUBE_CONFIG: ${{ secrets.AEACUS_KUBECONFIG }}
with:
args: apply -k docs/manifest
- name: Apply k8s manifests to aeacus
uses: actions-hub/kubectl@f632a31512a74cb35940627c49c20f67723cbaaf # v1.33.1
uses: actions-hub/kubectl@e81783053d902f50d752d21a6d99cf9689a652e1 # v1.33.0
env:
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
KUBE_CONFIG: ${{ secrets.AEACUS_KUBECONFIG }}
with:
args: rollout restart -n default deploy/anubis-docs

View File

@@ -28,7 +28,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: ./docs
cache-to: type=gha

View File

@@ -89,7 +89,7 @@ jobs:
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
uses: check-spelling/check-spelling@67debf50669c7fc76fc8f5d7f996384535a72b77 # v0.0.24
with:
suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}
checkout: true

View File

@@ -29,7 +29,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
with:
sarif_file: results.sarif
category: zizmor

View File

@@ -16,8 +16,6 @@ const CookieName = "techaro.lol-anubis-auth"
// WithDomainCookieName is the name that is prepended to the per-domain cookie used when COOKIE_DOMAIN is set.
const WithDomainCookieName = "techaro.lol-anubis-auth-for-"
const TestCookieName = "techaro.lol-anubis-cookie-test-if-you-block-this-anubis-wont-work"
// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
const CookieDefaultExpirationTime = 7 * 24 * time.Hour

View File

@@ -30,13 +30,11 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/thoth"
libanubis "github.com/TecharoHQ/anubis/lib"
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/web"
"github.com/facebookgo/flagenv"
_ "github.com/joho/godotenv/autoload"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
@@ -58,7 +56,6 @@ var (
redirectDomains = flag.String("redirect-domains", "", "list of domains separated by commas which anubis is allowed to redirect to. Leaving this unset allows any domain.")
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, set to an empty string to disable proxying when only using auth request")
targetHost = flag.String("target-host", "", "if set, the value of the Host header when forwarding requests to the target")
targetInsecureSkipVerify = flag.Bool("target-insecure-skip-verify", false, "if true, skips TLS validation for the backend")
healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis")
useRemoteAddress = flag.Bool("use-remote-address", false, "read the client's IP address from the network request, useful for debugging and running Anubis on bare metal")
@@ -68,9 +65,7 @@ var (
ogCacheConsiderHost = flag.Bool("og-cache-consider-host", false, "enable or disable the use of the host in the Open Graph tag cache")
extractResources = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder")
webmasterEmail = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals")
thothURL = flag.String("thoth-url", "", "if set, URL for Thoth, the IP reputation database for Anubis")
thothToken = flag.String("thoth-token", "", "if set, API token for Thoth, the IP reputation database for Anubis")
valkeyURL = flag.String("valkey-url", "", "Valkey URL for Anubis' state layer")
)
func keyFromHex(value string) (ed25519.PrivateKey, error) {
@@ -141,7 +136,7 @@ func setupListener(network string, address string) (net.Listener, string) {
return listener, formattedAddress
}
func makeReverseProxy(target string, targetHost string, insecureSkipVerify bool) (http.Handler, error) {
func makeReverseProxy(target string, insecureSkipVerify bool) (http.Handler, error) {
targetUri, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("failed to parse target URL: %w", err)
@@ -173,14 +168,6 @@ func makeReverseProxy(target string, targetHost string, insecureSkipVerify bool)
rp := httputil.NewSingleHostReverseProxy(targetUri)
rp.Transport = transport
if targetHost != "" {
originalDirector := rp.Director
rp.Director = func(req *http.Request) {
originalDirector(req)
req.Host = targetHost
}
}
return rp, nil
}
@@ -219,25 +206,13 @@ func main() {
// when using anubis via Systemd and environment variables, then it is not possible to set targe to an empty string but only to space
if strings.TrimSpace(*target) != "" {
var err error
rp, err = makeReverseProxy(*target, *targetHost, *targetInsecureSkipVerify)
rp, err = makeReverseProxy(*target, *targetInsecureSkipVerify)
if err != nil {
log.Fatalf("can't make reverse proxy: %v", err)
}
}
ctx := context.Background()
if *thothURL != "" && *thothToken != "" {
slog.Debug("connecting to Thoth")
thothClient, err := thoth.New(ctx, *thothURL, *thothToken)
if err != nil {
log.Fatalf("can't dial thoth at %s: %v", *thothURL, err)
}
ctx = thoth.With(ctx, thothClient)
}
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty)
policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, *challengeDifficulty)
if err != nil {
log.Fatalf("can't parse policy file: %v", err)
}

View File

@@ -1,20 +0,0 @@
# Make SASL login work on bookstack with Anubis
# https://www.bookstackapp.com/docs/admin/saml2-auth/
- name: allow-bookstack-sasl-login-routes
action: ALLOW
expression:
all:
- 'method == "POST"'
- path.startsWith("/saml2/acs")
- name: allow-bookstack-sasl-metadata-routes
action: ALLOW
expression:
all:
- 'method == "GET"'
- path.startsWith("/saml2/metadata")
- name: allow-bookstack-sasl-logout-routes
action: ALLOW
expression:
all:
- 'method == "GET"'
- path.startsWith("/saml2/sls")

View File

@@ -1,7 +0,0 @@
# This policy allows Qualys SSL Labs to fully work. (https://www.ssllabs.com/ssltest)
# IP ranges are taken from: https://qualys.my.site.com/discussions/s/article/000005823
- name: qualys-ssl-labs
action: ALLOW
remote_addresses:
- 64.41.200.0/24
- 2600:C02:1020:4202::/64

View File

@@ -1,9 +0,0 @@
# This policy allows SearXNG's instance tracker to work. (https://searx.space)
# IPs are taken from `check.searx.space` DNS records.
# https://toolbox.googleapps.com/apps/dig/#A/check.searx.space
# https://toolbox.googleapps.com/apps/dig/#AAAA/check.searx.space
- name: searx-checker
action: ALLOW
remote_addresses:
- 167.235.158.251/32
- 2a01:4f8:1c1c:8fc2::1/128

View File

@@ -48,7 +48,16 @@ bots:
- name: generic-browser
user_agent_regex: >-
Mozilla|Opera
action: CHALLENGE
action: WEIGH
weight:
adjust: 5
- name: high-pass-rate
pass_rate:
rate: 0.8
action: WEIGH
weight:
adjust: -15
dnsbl: false

View File

@@ -1,5 +1,5 @@
- name: deny-aggressive-brazilian-scrapers
action: DENY
- name: aggressive-brazilian-scrapers
action: WEIGH
expression:
any:
# Internet Explorer should be out of support
@@ -18,11 +18,9 @@
- userAgent.contains("Win 9x")
# Amazon does not have an Alexa Toolbar.
- userAgent.contains("Alexa Toolbar")
- name: challenge-aggressive-brazilian-scrapers
action: CHALLENGE
expression:
any:
# This is not released, even Windows 11 calls itself Windows 10
- userAgent.contains("Windows NT 11.0")
# iPods are not in common use
- userAgent.contains("iPod")
- userAgent.contains("iPod")
weight:
adjust: 10

View File

@@ -1,4 +1,4 @@
- name: "ai-robots-txt"
user_agent_regex: >-
AI2Bot|Ai2Bot-Dolma|aiHitBot|Amazonbot|anthropic-ai|Applebot|Applebot-Extended|Brightbot 1.0|Bytespider|CCBot|ChatGPT-User|Claude-Web|ClaudeBot|cohere-ai|cohere-training-data-crawler|Cotoyogi|Crawlspace|Diffbot|DuckAssistBot|FacebookBot|Factset_spyderbot|FirecrawlAgent|FriendlyCrawler|Google-Extended|GoogleOther|GoogleOther-Image|GoogleOther-Video|GPTBot|iaskspider/2.0|ICC-Crawler|ImagesiftBot|img2dataset|imgproxy|ISSCyberRiskCrawler|Kangaroo Bot|meta-externalagent|Meta-ExternalAgent|meta-externalfetcher|Meta-ExternalFetcher|NovaAct|OAI-SearchBot|omgili|omgilibot|Operator|PanguBot|Perplexity-User|PerplexityBot|PetalBot|QualifiedBot|Scrapy|SemrushBot-OCOB|SemrushBot-SWA|Sidetrade indexer bot|TikTokSpider|Timpibot|VelenPublicWebCrawler|Webzio-Extended|YouBot
AI2Bot|Ai2Bot-Dolma|aiHitBot|Amazonbot|anthropic-ai|Applebot|Applebot-Extended|Brightbot 1.0|Bytespider|CCBot|ChatGPT-User|Claude-Web|ClaudeBot|cohere-ai|cohere-training-data-crawler|Cotoyogi|Crawlspace|Diffbot|DuckAssistBot|FacebookBot|Factset_spyderbot|FirecrawlAgent|FriendlyCrawler|Google-Extended|GoogleOther|GoogleOther-Image|GoogleOther-Video|GPTBot|iaskspider/2.0|ICC-Crawler|ImagesiftBot|img2dataset|imgproxy|ISSCyberRiskCrawler|Kangaroo Bot|meta-externalagent|Meta-ExternalAgent|meta-externalfetcher|Meta-ExternalFetcher|NovaAct|OAI-SearchBot|omgili|omgilibot|Operator|PanguBot|Perplexity-User|PerplexityBot|PetalBot|Scrapy|SemrushBot-OCOB|SemrushBot-SWA|Sidetrade indexer bot|TikTokSpider|Timpibot|VelenPublicWebCrawler|Webzio-Extended|YouBot
action: DENY

View File

@@ -1,4 +1,6 @@
- name: cloudflare-workers
headers_regex:
CF-Worker: .*
action: DENY
action: WEIGH
weight:
adjust: 5

View File

@@ -1,9 +1,14 @@
- name: lightpanda
user_agent_regex: ^LightPanda/.*$
action: DENY
action: WEIGH
weight:
adjust: 5
- name: headless-chrome
user_agent_regex: HeadlessChrome
action: DENY
action: WEIGH
weight:
adjust: 5
- name: headless-chromium
user_agent_regex: HeadlessChromium
action: DENY
weight:
adjust: 5

View File

@@ -1,3 +1,5 @@
- name: no-user-agent-string
action: DENY
expression: userAgent == ""
action: WEIGH
expression: userAgent == ""
weight:
adjust: 10

View File

@@ -11,8 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Ensure that clients that are shown a challenge support storing cookies
- Encode challenge pages with gzip level 1
- Add `check-spelling` for spell checking
- Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend
- Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration.
@@ -20,13 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated the nonce value in the challenge JWT cookie to be a string instead of a number
- Rename cookies in response to user feedback
- Ensure cookie renaming is consistent across configuration options
- Add Bookstack app in data
- Add `--target-host` flag/envvar to allow changing the value of the Host header in requests forwarded to the target service.
- Bump AI-robots.txt to version 1.30 (add QualifiedBot)
- Add `RuntimeDirectory` to systemd unit settings so native packages can listen over unix sockets
- Added SearXNG instance tracker whitelist policy
- Added Qualys SSL Labs whitelist policy
- Fixed cookie deletion logic ([#520](https://github.com/TecharoHQ/anubis/issues/520), [#522](https://github.com/TecharoHQ/anubis/pull/522))
## v1.18.0: Varis zos Galvus
@@ -52,7 +43,7 @@ Or as complicated as:
expression:
all:
- >-
(
(
userAgent.startsWith("git/") ||
userAgent.contains("libgit") ||
userAgent.startsWith("go-git") ||

View File

@@ -1,8 +0,0 @@
{
"label": "Frameworks",
"position": 30,
"link": {
"type": "generated-index",
"description": "Information about getting specific frameworks or tools working with Anubis."
}
}

View File

@@ -1,45 +0,0 @@
# HTMX
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
[HTMX](https://htmx.org) is a framework that enables you to write applications using hypertext as the engine of application state. This enables you to simplify you server side code by having it return HTML instead of JSON. This can interfere with Anubis because Anubis challenge pages also return HTML.
To work around this, you can make a custom [expression](../configuration/expressions.mdx) rule that allows HTMX requests if the user has passed a challenge in the past:
<Tabs>
<TabItem value="json" label="JSON">
```json
{
"name": "allow-htmx-iff-already-passed-challenge",
"action": "ALLOW",
"expression": {
"all": [
"\"Cookie\" in headers",
"headers[\"Cookie\"].contains(\"anubis-auth\")",
"\"Hx-Request\" in headers",
"headers[\"Hx-Request\"] == \"true\""
]
}
}
```
</TabItem>
<TabItem value="yaml" label="YAML" default>
```yaml
- name: allow-htmx-iff-already-passed-challenge
action: ALLOW
expression:
all:
- '"Cookie" in headers'
- 'headers["Cookie"].contains("anubis-auth")'
- '"Hx-Request" in headers'
- 'headers["Hx-Request"] == "true"'
```
</TabItem>
</Tabs>
This will reduce some security because it does not assert the validity of the Anubis auth cookie, however in trade it improves the experience for existing users.

View File

@@ -54,7 +54,7 @@ Anubis uses these environment variables for configuration:
| `BASE_PREFIX` | unset | If set, adds a global prefix to all Anubis endpoints. For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes. |
| `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 this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information.<br/><br/>Note that unlike `REDIRECT_DOMAINS`, you should never include a port number in this variable. |
| `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 this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information. |
| `COOKIE_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. |
| `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` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
@@ -66,7 +66,7 @@ Anubis uses these environment variables for configuration:
| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. |
| `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. |
| `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. |
| `REDIRECT_DOMAINS` | unset | If set, restrict the domains that Anubis can redirect to when passing a challenge.<br/><br/>If this is unset, Anubis may redirect to any domain which could cause security issues in the unlikely case that an attacker passes a challenge for your browser and then tricks you into clicking a link to your domain.<br/><br/>Note that if you are hosting Anubis on a non-standard port (`https://example:com:8443`, `http://www.example.net:8080`, etc.), you must also include the port number here. |
| `REDIRECT_DOMAINS` | unset | If set, restrict the domains that Anubis can redirect to when passing a challenge.<br/><br/>If this is unset, Anubis may redirect to any domain which could cause security issues in the unlikely case that an attacker passes a challenge for your browser and then tricks you into clicking a link to your domain. |
| `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. |
| `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. |
| `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`. |
@@ -84,7 +84,6 @@ If you don't know or understand what these settings mean, ignore them. These are
| Environment Variable | Default value | Explanation |
| :---------------------------- | :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TARGET_HOST` | unset | If set, overrides the Host header in requests forwarded to `TARGET`. |
| `TARGET_INSECURE_SKIP_VERIFY` | `false` | If `true`, skip TLS certificate validation for targets that listen over `https`. If your backend does not listen over `https`, ignore this setting. |
</details>

View File

@@ -34,7 +34,6 @@ This page contains a non-exhaustive list with all websites using Anubis.
- https://bugzilla.proxmox.com
- https://hofstede.io/
- https://www.indiemag.fr/
- https://reddit.nerdvpn.de/
- <details>
<summary>FreeCAD</summary>
- https://forum.freecad.org/

17
go.mod
View File

@@ -3,26 +3,21 @@ module github.com/TecharoHQ/anubis
go 1.24.2
require (
github.com/TecharoHQ/thoth-proto v0.2.0
github.com/a-h/templ v0.3.865
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
github.com/gaissmai/bart v0.20.4
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/cel-go v0.25.0
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1
github.com/joho/godotenv v1.5.1
github.com/playwright-community/playwright-go v0.5200.0
github.com/prometheus/client_golang v1.22.0
github.com/redis/go-redis/v9 v9.8.0
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
github.com/yl2chen/cidranger v1.0.2
golang.org/x/net v0.40.0
google.golang.org/grpc v1.72.1
k8s.io/apimachinery v0.33.0
)
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 // indirect
cel.dev/expr v0.23.1 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
@@ -47,6 +42,7 @@ require (
github.com/cloudflare/circl v1.6.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/deckarep/golang-set/v2 v2.7.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c // indirect
github.com/emirpasic/gods v1.18.1 // indirect
@@ -70,7 +66,6 @@ require (
github.com/goreleaser/chglog v0.7.0 // indirect
github.com/goreleaser/fileglob v1.3.0 // indirect
github.com/goreleaser/nfpm/v2 v2.42.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
@@ -91,7 +86,7 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
@@ -106,9 +101,9 @@ require (
golang.org/x/tools v0.32.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/protobuf v1.36.6 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect

62
go.sum
View File

@@ -1,7 +1,5 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
@@ -30,8 +28,6 @@ github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ
github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs=
github.com/Songmu/gitconfig v0.2.0 h1:pX2++u4KUq+K2k/ZCzGXLtkD3ceCqIdi0tDyb+IbSyo=
github.com/Songmu/gitconfig v0.2.0/go.mod h1:cB5bYJer+pl7W8g6RHFwL/0X6aJROVrYuHlvc7PT+hE=
github.com/TecharoHQ/thoth-proto v0.2.0 h1:IR/LMbr4phOPgfgmQ+VNBYfckGoo/xr5xlWqsORF8/8=
github.com/TecharoHQ/thoth-proto v0.2.0/go.mod h1:wIkQ7hMmNk2XZXRVeL1WcioD4sc1pCCEAHbJ8hKG51A=
github.com/TecharoHQ/yeet v0.2.3 h1:Pcsnq5HTnk4Xntlu/FNEidH7x55bIx+f5Mk1hpVIngs=
github.com/TecharoHQ/yeet v0.2.3/go.mod h1:avLiwxZpNY37A/o35XledvdmGnTkm3G7+Oskxca6Z7Y=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
@@ -50,6 +46,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8=
github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk=
github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM=
@@ -77,6 +77,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
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.7.0 h1:gIloKvD7yH2oip4VLhsv3JyLLFnC0Y2mlusgcvJYW5k=
github.com/deckarep/golang-set/v2 v2.7.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg=
@@ -101,8 +103,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gaissmai/bart v0.20.4 h1:Ik47r1fy3jRVU+1eYzKSW3ho2UgBVTVnUS8O993584U=
github.com/gaissmai/bart v0.20.4/go.mod h1:cEed+ge8dalcbpi8wtS9x9m2hn/fNJH5suhdGQOHnYk=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -115,10 +115,6 @@ github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
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-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@@ -140,8 +136,6 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=
@@ -167,18 +161,12 @@ github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+
github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU=
github.com/goreleaser/nfpm/v2 v2.42.0 h1:7BW4WQWyvZDrT0C7SyWop+J8rtqFyTB17Sb2/j/NxMI=
github.com/goreleaser/nfpm/v2 v2.42.0/go.mod h1:DtNL+nKpfB8sMFZp+X7Xu3W64atyZYtTnYe8O925/mg=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -242,6 +230,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -262,17 +252,13 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
@@ -287,18 +273,6 @@ github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -383,14 +357,12 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,35 +0,0 @@
package internal
import (
"compress/gzip"
"net/http"
"strings"
)
func GzipMiddleware(level int, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz, err := gzip.NewWriterLevel(w, level)
if err != nil {
panic(err)
}
defer gz.Close()
grw := gzipResponseWriter{ResponseWriter: w, sink: gz}
next.ServeHTTP(grw, r)
})
}
type gzipResponseWriter struct {
http.ResponseWriter
sink *gzip.Writer
}
func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.sink.Write(b)
}

View File

@@ -0,0 +1,86 @@
package valkey
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
valkey "github.com/redis/go-redis/v9"
)
type Store struct {
rdb *valkey.Client
}
func New(rdb *valkey.Client) *Store {
return &Store{rdb: rdb}
}
func (s *Store) Increment(ctx context.Context, segments []string) error {
key := fmt.Sprintf("anubis:%s", strings.Join(segments, ":"))
if err := s.rdb.Incr(ctx, key).Err(); err != nil {
return err
}
return nil
}
func (s *Store) GetInt(ctx context.Context, segments []string) (int, error) {
key := fmt.Sprintf("anubis:%s", strings.Join(segments, ":"))
numStr, err := s.rdb.Get(ctx, key).Result()
if err != nil {
return 0, err
}
num, err := strconv.Atoi(numStr)
if err != nil {
return 0, err
}
return num, nil
}
func (s *Store) MultiGetInt(ctx context.Context, segments [][]string) ([]int, error) {
var keys []string
for _, segment := range segments {
key := fmt.Sprintf("anubis:%s", strings.Join(segment, ":"))
keys = append(keys, key)
}
values, err := s.rdb.MGet(ctx, keys...).Result()
if err != nil {
return nil, err
}
var errs []error
result := make([]int, len(values))
for i, val := range values {
if val == nil {
result[i] = 0
errs = append(errs, fmt.Errorf("can't get key %s: value is null", keys[i]))
continue
}
switch v := val.(type) {
case string:
num, err := strconv.Atoi(v)
if err != nil {
errs = append(errs, fmt.Errorf("can't parse key %s: %w", keys[i], err))
continue
}
result[i] = num
default:
errs = append(errs, fmt.Errorf("can't parse key %s: wanted type string but got type %T", keys[i], val))
}
}
if len(errs) != 0 {
return nil, fmt.Errorf("can't read from valkey: %w", errors.Join(errs...))
}
return result, nil
}

View File

@@ -1,39 +0,0 @@
package thoth
import (
"context"
"net/http"
"time"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)
type ASNChecker struct {
iptoasn iptoasnv1.IpToASNServiceClient
asns map[uint32]struct{}
hash string
}
func (asnc *ASNChecker) Check(r *http.Request) (bool, error) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
ipInfo, err := asnc.iptoasn.Lookup(ctx, &iptoasnv1.LookupRequest{
IpAddress: r.Header.Get("X-Real-Ip"),
})
if err != nil {
return false, err
}
if !ipInfo.GetAnnounced() {
return false, nil
}
_, ok := asnc.asns[uint32(ipInfo.GetAsNumber())]
return ok, nil
}
func (asnc *ASNChecker) Hash() string {
return asnc.hash
}

View File

@@ -1,81 +0,0 @@
package thoth
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/TecharoHQ/anubis/lib/policy/checker"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)
var _ checker.Impl = &ASNChecker{}
func TestASNChecker(t *testing.T) {
cli := loadSecrets(t)
asnc := &ASNChecker{
iptoasn: cli.iptoasn,
asns: map[uint32]struct{}{
13335: {},
},
hash: "foobar",
}
for _, cs := range []struct {
ipAddress string
wantMatch bool
wantError bool
}{
{
ipAddress: "1.1.1.1",
wantMatch: true,
wantError: false,
},
{
ipAddress: "8.8.8.8",
wantMatch: false,
wantError: false,
},
{
ipAddress: "taco",
wantMatch: false,
wantError: true,
},
} {
t.Run(fmt.Sprintf("%v", cs), func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-Ip", cs.ipAddress)
match, err := asnc.Check(req)
if match != cs.wantMatch {
t.Errorf("Wanted match: %v, got: %v", cs.wantMatch, match)
}
switch {
case err != nil && !cs.wantError:
t.Errorf("Did not want error but got: %v", err)
case err == nil && cs.wantError:
t.Error("Wanted error but got none")
}
})
}
}
func BenchmarkWithCache(b *testing.B) {
cli := loadSecrets(b)
req := &iptoasnv1.LookupRequest{IpAddress: "1.1.1.1"}
_, err := cli.iptoasn.Lookup(b.Context(), req)
if err != nil {
b.Error(err)
}
for b.Loop() {
_, err := cli.iptoasn.Lookup(b.Context(), req)
if err != nil {
b.Error(err)
}
}
}

View File

@@ -1,39 +0,0 @@
package thoth
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor {
return func(
ctx context.Context,
method string,
req interface{},
reply interface{},
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor {
return func(
ctx context.Context,
desc *grpc.StreamDesc,
cc *grpc.ClientConn,
method string,
streamer grpc.Streamer,
opts ...grpc.CallOption,
) (grpc.ClientStream, error) {
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
return streamer(ctx, desc, cc, method, opts...)
}
}

View File

@@ -1,84 +0,0 @@
package thoth
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
"github.com/gaissmai/bart"
"google.golang.org/grpc"
)
type IPToASNWithCache struct {
next iptoasnv1.IpToASNServiceClient
table *bart.Table[*iptoasnv1.LookupResponse]
}
func NewIpToASNWithCache(next iptoasnv1.IpToASNServiceClient) *IPToASNWithCache {
result := &IPToASNWithCache{
next: next,
table: &bart.Table[*iptoasnv1.LookupResponse]{},
}
for _, pfx := range []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"), // RFC 1918
netip.MustParsePrefix("172.16.0.0/12"), // RFC 1918
netip.MustParsePrefix("192.168.0.0/16"), // RFC 1918
netip.MustParsePrefix("127.0.0.0/8"), // Loopback
netip.MustParsePrefix("169.254.0.0/16"), // Link-local
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT
netip.MustParsePrefix("192.0.0.0/24"), // Protocol assignments
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
netip.MustParsePrefix("240.0.0.0/4"), // Reserved
netip.MustParsePrefix("255.255.255.255/32"), // Broadcast
netip.MustParsePrefix("fc00::/7"), // Unique local address
netip.MustParsePrefix("fe80::/10"), // Link-local
netip.MustParsePrefix("::1/128"), // Loopback
netip.MustParsePrefix("::/128"), // Unspecified
netip.MustParsePrefix("100::/64"), // Discard-only
netip.MustParsePrefix("2001:db8::/32"), // Documentation
} {
result.table.Insert(pfx, &iptoasnv1.LookupResponse{Announced: false})
}
return result
}
func (ip2asn *IPToASNWithCache) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) {
addr, err := netip.ParseAddr(lr.GetIpAddress())
if err != nil {
return nil, fmt.Errorf("input is not an IP address: %w", err)
}
cachedResponse, ok := ip2asn.table.Lookup(addr)
if ok {
return cachedResponse, nil
}
resp, err := ip2asn.next.Lookup(ctx, lr, opts...)
if err != nil {
return nil, err
}
var errs []error
for _, cidr := range resp.GetCidr() {
pfx, err := netip.ParsePrefix(cidr)
if err != nil {
errs = append(errs, err)
continue
}
ip2asn.table.Insert(pfx, resp)
}
if len(errs) != 0 {
slog.Error("errors parsing IP prefixes", "err", errors.Join(errs...))
}
return resp, nil
}

View File

@@ -1,14 +0,0 @@
package thoth
import "context"
type ctxKey struct{}
func With(ctx context.Context, cli *Client) context.Context {
return context.WithValue(ctx, ctxKey{}, cli)
}
func FromContext(ctx context.Context) (*Client, bool) {
cli, ok := ctx.Value(ctxKey{}).(*Client)
return cli, ok
}

View File

@@ -1,40 +0,0 @@
package thoth
import (
"context"
"net/http"
"strings"
"time"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)
type GeoIPChecker struct {
iptoasn iptoasnv1.IpToASNServiceClient
countries map[string]struct{}
hash string
}
func (gipc *GeoIPChecker) Check(r *http.Request) (bool, error) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
ipInfo, err := gipc.iptoasn.Lookup(ctx, &iptoasnv1.LookupRequest{
IpAddress: r.Header.Get("X-Real-Ip"),
})
if err != nil {
return false, err
}
if !ipInfo.GetAnnounced() {
return false, nil
}
_, ok := gipc.countries[strings.ToLower(ipInfo.GetCountryCode())]
return ok, nil
}
func (gipc *GeoIPChecker) Hash() string {
return gipc.hash
}

View File

@@ -1,63 +0,0 @@
package thoth
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/TecharoHQ/anubis/lib/policy/checker"
)
var _ checker.Impl = &ASNChecker{}
func TestGeoIPChecker(t *testing.T) {
cli := loadSecrets(t)
asnc := &GeoIPChecker{
iptoasn: cli.iptoasn,
countries: map[string]struct{}{
"us": {},
},
hash: "foobar",
}
for _, cs := range []struct {
ipAddress string
wantMatch bool
wantError bool
}{
{
ipAddress: "1.1.1.1",
wantMatch: true,
wantError: false,
},
{
ipAddress: "70.31.0.1",
wantMatch: false,
wantError: false,
},
{
ipAddress: "taco",
wantMatch: false,
wantError: true,
},
} {
t.Run(fmt.Sprintf("%v", cs), func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-Ip", cs.ipAddress)
match, err := asnc.Check(req)
if match != cs.wantMatch {
t.Errorf("Wanted match: %v, got: %v", cs.wantMatch, match)
}
switch {
case err != nil && !cs.wantError:
t.Errorf("Did not want error but got: %v", err)
case err == nil && cs.wantError:
t.Error("Wanted error but got none")
}
})
}
}

View File

@@ -1,114 +0,0 @@
package thoth
import (
"context"
"crypto/tls"
"fmt"
"strings"
"time"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy/checker"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout"
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
)
type Client struct {
thothURL string
conn *grpc.ClientConn
health healthv1.HealthClient
iptoasn iptoasnv1.IpToASNServiceClient
}
func New(ctx context.Context, thothURL, apiToken string) (*Client, error) {
clMetrics := grpcprom.NewClientMetrics(
grpcprom.WithClientHandlingTimeHistogram(
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),
),
)
prometheus.DefaultRegisterer.Register(clMetrics)
conn, err := grpc.DialContext(
ctx,
thothURL,
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})),
grpc.WithChainUnaryInterceptor(
timeout.UnaryClientInterceptor(500*time.Millisecond),
clMetrics.UnaryClientInterceptor(),
authUnaryClientInterceptor(apiToken),
),
grpc.WithChainStreamInterceptor(
clMetrics.StreamClientInterceptor(),
authStreamClientInterceptor(apiToken),
),
)
if err != nil {
return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err)
}
hc := healthv1.NewHealthClient(conn)
resp, err := hc.Check(ctx, &healthv1.HealthCheckRequest{})
if err != nil {
return nil, fmt.Errorf("can't verify thoth health at %s: %w", thothURL, err)
}
if resp.Status != healthv1.HealthCheckResponse_SERVING {
return nil, fmt.Errorf("thoth is not healthy, wanted %s but got %s", healthv1.HealthCheckResponse_SERVING, resp.Status)
}
return &Client{
conn: conn,
health: hc,
iptoasn: NewIpToASNWithCache(iptoasnv1.NewIpToASNServiceClient(conn)),
}, nil
}
func (c *Client) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func (c *Client) WithIPToASNService(impl iptoasnv1.IpToASNServiceClient) {
c.iptoasn = impl
}
func (c *Client) ASNCheckerFor(asns []uint32) checker.Impl {
asnMap := map[uint32]struct{}{}
var sb strings.Builder
fmt.Fprintln(&sb, "ASNChecker")
for _, asn := range asns {
asnMap[asn] = struct{}{}
fmt.Fprintln(&sb, "AS", asn)
}
return &ASNChecker{
iptoasn: c.iptoasn,
asns: asnMap,
hash: internal.SHA256sum(sb.String()),
}
}
func (c *Client) GeoIPCheckerFor(countries []string) checker.Impl {
countryMap := map[string]struct{}{}
var sb strings.Builder
fmt.Fprintln(&sb, "GeoIPChecker")
for _, cc := range countries {
countryMap[cc] = struct{}{}
fmt.Fprintln(&sb, cc)
}
return &GeoIPChecker{
iptoasn: c.iptoasn,
countries: countryMap,
hash: sb.String(),
}
}

View File

@@ -1,29 +0,0 @@
package thoth
import (
"os"
"testing"
"github.com/joho/godotenv"
)
func loadSecrets(t testing.TB) *Client {
if err := godotenv.Load(); err != nil {
t.Skip(".env not defined, can't load thoth secrets")
}
cli, err := New(t.Context(), os.Getenv("THOTH_URL"), os.Getenv("THOTH_API_KEY"))
if err != nil {
t.Fatal(err)
}
return cli
}
func TestNew(t *testing.T) {
cli := loadSecrets(t)
if err := cli.Close(); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,44 +0,0 @@
package thothmock
import (
"context"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func MockIpToASNService() *IpToASNService {
responses := map[string]*iptoasnv1.LookupResponse{
"1.1.1.1": {
Announced: true,
AsNumber: 13335,
Cidr: []string{"1.1.1.0/24"},
CountryCode: "US",
Description: "Cloudflare",
},
"2.2.2.2": {
Announced: true,
AsNumber: 420,
Cidr: []string{"2.2.2.0/24"},
CountryCode: "CA",
Description: "test canada",
},
}
return &IpToASNService{Responses: responses}
}
type IpToASNService struct {
Responses map[string]*iptoasnv1.LookupResponse
}
func (ip2asn *IpToASNService) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) {
resp, ok := ip2asn.Responses[lr.GetIpAddress()]
if !ok {
return nil, status.Error(codes.NotFound, "IP address not found in mock")
}
return resp, nil
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/internal/store/valkey"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
@@ -68,6 +69,7 @@ type Server struct {
pub ed25519.PublicKey
opts Options
cookieName string
store *valkey.Store
}
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
@@ -96,12 +98,6 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
lg := internal.GetRequestLogger(r)
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
cr, rule, err := s.check(r)
if err != nil {
lg.Error("check failed", "err", err)
@@ -127,21 +123,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
ckie, err := r.Cookie(s.cookieName)
if err != nil {
lg.Debug("cookie not found", "path", r.URL.Path)
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
s.RenderIndex(w, r, rule, httpStatusOnly)
return
}
if err := ckie.Valid(); err != nil {
lg.Debug("cookie is invalid", "err", err)
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
s.RenderIndex(w, r, rule, httpStatusOnly)
return
}
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
lg.Debug("cookie expired", "path", r.URL.Path)
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
s.RenderIndex(w, r, rule, httpStatusOnly)
return
}
@@ -152,7 +148,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
if err != nil || !token.Valid {
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
s.RenderIndex(w, r, rule, httpStatusOnly)
return
}
@@ -162,19 +158,13 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
}
func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
switch cr.Rule {
case config.RuleAllow:
lg.Debug("allowing traffic to origin (explicit)")
s.ServeHTTPNext(w, r)
return true
case config.RuleDeny:
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Info("explicit deny")
if rule == nil {
lg.Error("rule is nil, cannot calculate checksum")
@@ -193,7 +183,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
s.RenderBench(w, r)
return true
default:
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"")
return true
@@ -245,7 +235,9 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
lg = lg.With("check_result", cr)
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
s.SetCookie(w, anubis.TestCookieName, challenge, "/")
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "challenges_issued"})
}
err = encoder.Encode(struct {
Rules *config.ChallengeRules `json:"rules"`
@@ -266,14 +258,6 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
lg := internal.GetRequestLogger(r)
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
s.ClearCookie(w, anubis.TestCookieName, "/")
redir := r.FormValue("redir")
redirURL, err := url.ParseRequestURI(redir)
if err != nil {
@@ -287,14 +271,14 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
cr, rule, err := s.check(r)
if err != nil {
lg.Error("check failed", "err", err)
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".")
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".\"")
return
}
lg = lg.With("check_result", cr)
nonceStr := r.FormValue("nonce")
if nonceStr == "" {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Debug("no nonce")
s.respondWithError(w, r, "missing nonce")
return
@@ -302,7 +286,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
elapsedTimeStr := r.FormValue("elapsedTime")
if elapsedTimeStr == "" {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Debug("no elapsedTime")
s.respondWithError(w, r, "missing elapsedTime")
return
@@ -310,7 +294,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
if err != nil {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Debug("elapsedTime doesn't parse", "err", err)
s.respondWithError(w, r, "invalid elapsedTime")
return
@@ -332,19 +316,11 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
if _, err := r.Cookie(anubis.TestCookieName); err == http.ErrNoCookie {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w, anubis.TestCookieName, cookiePath)
lg.Warn("user has cookies disabled, this is not an anubis bug")
s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain")
return
}
nonce, err := strconv.Atoi(nonceStr)
if err != nil {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Debug("nonce doesn't parse", "err", err)
s.respondWithError(w, r, "invalid response")
s.respondWithError(w, r, "invalid nonce")
return
}
@@ -352,22 +328,33 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
calculated := internal.SHA256sum(calcString)
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Debug("hash does not match", "got", response, "want", calculated)
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "fail"})
}
failedValidations.Inc()
return
}
// compare the leading zeroes
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "fail"})
}
failedValidations.Inc()
return
}
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
if anubis.BasePrefix != "" {
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
// generate JWT cookie
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
"challenge": challenge,
@@ -380,12 +367,24 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
tokenString, err := token.SignedString(s.priv)
if err != nil {
lg.Error("failed to sign JWT", "err", err)
s.ClearCookie(w, s.cookieName, cookiePath)
s.ClearCookie(w)
s.respondWithError(w, r, "failed to sign JWT")
return
}
s.SetCookie(w, s.cookieName, tokenString, cookiePath)
http.SetCookie(w, &http.Cookie{
Name: s.cookieName,
Value: tokenString,
Expires: time.Now().Add(s.opts.CookieExpiration),
SameSite: http.SameSiteLaxMode,
Domain: s.opts.CookieDomain,
Partitioned: s.opts.CookiePartitioned,
Path: cookiePath,
})
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "pass"})
}
challengesValidated.Inc()
lg.Debug("challenge passed, redirecting to app")
@@ -416,6 +415,8 @@ func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error)
return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
}
weight := 0
for _, b := range s.policy.Bots {
match, err := b.Rules.Check(r)
if err != nil {
@@ -423,10 +424,27 @@ func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error)
}
if match {
return cr("bot/"+b.Name, b.Action), &b, nil
switch b.Action {
case config.RuleDeny, config.RuleAllow, config.RuleBenchmark:
return cr("bot/"+b.Name, b.Action), &b, nil
case config.RuleChallenge:
weight += 5
case config.RuleWeigh:
weight += b.Weight.Adjust
}
}
}
if weight < 0 {
return cr("weight/okay", config.RuleAllow), &policy.Bot{
Challenge: &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty,
ReportAs: s.policy.DefaultDifficulty,
Algorithm: config.AlgorithmFast,
},
}, nil
}
return cr("default/allow", config.RuleAllow), &policy.Bot{
Challenge: &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty,

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"os"
"strings"
@@ -14,8 +13,6 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/thoth"
"github.com/TecharoHQ/anubis/internal/thoth/thothmock"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
@@ -23,11 +20,7 @@ import (
func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
t.Helper()
thothCli := &thoth.Client{}
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
ctx := thoth.With(t.Context(), thothCli)
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, anubis.DefaultDifficulty)
anubisPolicy, err := LoadPoliciesOrDefault(fname, anubis.DefaultDifficulty)
if err != nil {
t.Fatal(err)
}
@@ -50,10 +43,10 @@ type challenge struct {
Challenge string `json:"challenge"`
}
func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challenge {
func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
t.Helper()
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
resp, err := ts.Client().Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
if err != nil {
t.Fatalf("can't request challenge: %v", err)
}
@@ -67,8 +60,109 @@ func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challeng
return chall
}
func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challenge) *http.Response {
t.Helper()
func TestLoadPolicies(t *testing.T) {
for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
t.Run(fname, func(t *testing.T) {
fin, err := data.BotPolicies.Open(fname)
if err != nil {
t.Fatal(err)
}
defer fin.Close()
if _, err := policy.ParseConfig(fin, fname, 4); err != nil {
t.Fatal(err)
}
})
}
}
// Regression test for CVE-2025-24369
func TestCVE2025_24369(t *testing.T) {
pol := loadPolicies(t, "")
pol.DefaultDifficulty = 4
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
CookieDomain: ".local.cetacean.club",
CookiePartitioned: true,
CookieName: t.Name(),
})
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
defer ts.Close()
chall := makeChallenge(t, ts)
calcString := fmt.Sprintf("%s%d", chall.Challenge, 0)
calculated := internal.SHA256sum(calcString)
nonce := 0
elapsedTime := 420
redir := "/"
cli := ts.Client()
cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
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.Log("Regression on CVE-2025-24369")
t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestCookieCustomExpiration(t *testing.T) {
pol := loadPolicies(t, "")
pol.DefaultDifficulty = 0
ckieExpiration := 10 * time.Minute
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
CookieDomain: "local.cetacean.club",
CookieName: t.Name(),
CookieExpiration: ckieExpiration,
})
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", 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
@@ -89,95 +183,12 @@ func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
req.URL.RawQuery = q.Encode()
resp, err := cli.Do(req)
if err != nil {
t.Fatalf("can't do request: %v", err)
}
return resp
}
func httpClient(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
cli := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
return cli
}
func TestLoadPolicies(t *testing.T) {
for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
t.Run(fname, func(t *testing.T) {
fin, err := data.BotPolicies.Open(fname)
if err != nil {
t.Fatal(err)
}
defer fin.Close()
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4); err != nil {
t.Fatal(err)
}
})
}
}
// Regression test for CVE-2025-24369
func TestCVE2025_24369(t *testing.T) {
pol := loadPolicies(t, "")
pol.DefaultDifficulty = 4
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
CookiePartitioned: true,
CookieName: t.Name(),
})
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
defer ts.Close()
cli := httpClient(t)
chall := makeChallenge(t, ts, cli)
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
if resp.StatusCode == http.StatusFound {
t.Log("Regression on CVE-2025-24369")
t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
}
}
func TestCookieCustomExpiration(t *testing.T) {
pol := loadPolicies(t, "")
pol.DefaultDifficulty = 0
ckieExpiration := 10 * time.Minute
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
CookieExpiration: ckieExpiration,
})
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
defer ts.Close()
cli := httpClient(t)
chall := makeChallenge(t, ts, cli)
requestReceiveLowerBound := time.Now().Add(-1 * time.Minute)
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
requestReceiveLowerBound := time.Now()
resp, err = cli.Do(req)
requestReceiveUpperBound := time.Now()
if err != nil {
t.Fatalf("can't do challenge passing")
}
if resp.StatusCode != http.StatusFound {
resp.Write(os.Stderr)
@@ -215,21 +226,59 @@ func TestCookieSettings(t *testing.T) {
Next: http.NewServeMux(),
Policy: pol,
CookieDomain: "127.0.0.1",
CookieDomain: "local.cetacean.club",
CookiePartitioned: true,
CookieName: t.Name(),
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
requestReceiveLowerBound := time.Now()
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
defer ts.Close()
cli := httpClient(t)
chall := makeChallenge(t, ts, cli)
cli := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
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 := "/"
calculated := ""
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()
requestReceiveLowerBound := time.Now()
resp, err = cli.Do(req)
requestReceiveUpperBound := time.Now()
if err != nil {
t.Fatalf("can't do challenge passing")
}
if resp.StatusCode != http.StatusFound {
resp.Write(os.Stderr)
@@ -249,8 +298,8 @@ func TestCookieSettings(t *testing.T) {
return
}
if ckie.Domain != "127.0.0.1" {
t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
if ckie.Domain != "local.cetacean.club" {
t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
}
expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
@@ -274,7 +323,7 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
for i := 1; i < 10; i++ {
t.Run(fmt.Sprint(i), func(t *testing.T) {
anubisPolicy, err := LoadPoliciesOrDefault(t.Context(), "", i)
anubisPolicy, err := LoadPoliciesOrDefault("", i)
if err != nil {
t.Fatal(err)
}
@@ -408,10 +457,6 @@ func TestBasePrefix(t *testing.T) {
t.Fatalf("can't make request: %v", err)
}
for _, ckie := range resp.Cookies() {
req.AddCookie(ckie)
}
q := req.URL.Query()
q.Set("response", calculated)
q.Set("nonce", fmt.Sprint(nonce))
@@ -516,25 +561,6 @@ func TestCloudflareWorkersRule(t *testing.T) {
t.Fatalf("can't construct libanubis.Server: %v", err)
}
t.Run("with-cf-worker-header", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Add("X-Real-Ip", "127.0.0.1")
req.Header.Add("Cf-Worker", "true")
cr, _, err := s.check(req)
if err != nil {
t.Fatal(err)
}
if cr.Rule != config.RuleDeny {
t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleDeny, cr.Rule)
}
})
t.Run("no-cf-worker-header", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {

View File

@@ -1,7 +1,6 @@
package lib
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
@@ -41,7 +40,7 @@ type Options struct {
ServeRobotsTXT bool
}
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
var fin io.ReadCloser
var err error
@@ -65,7 +64,7 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
}
}(fin)
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty)
anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty)
return anubisPolicy, err
}

View File

@@ -1,41 +1,24 @@
package lib
import (
"math/rand"
"net/http"
"slices"
"strings"
"time"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ"
)
func (s *Server) SetCookie(w http.ResponseWriter, name, value, path string) {
func (s *Server) ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Expires: time.Now().Add(s.opts.CookieExpiration),
SameSite: http.SameSiteLaxMode,
Domain: s.opts.CookieDomain,
Partitioned: s.opts.CookiePartitioned,
Path: path,
})
}
func (s *Server) ClearCookie(w http.ResponseWriter, name, path string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
MaxAge: -1,
Expires: time.Now().Add(-1 * time.Minute),
SameSite: http.SameSiteLaxMode,
Partitioned: s.opts.CookiePartitioned,
Domain: s.opts.CookieDomain,
Path: path,
Name: s.cookieName,
Value: "",
Expires: time.Now().Add(-1 * time.Hour),
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
Domain: s.opts.CookieDomain,
})
}
@@ -55,10 +38,6 @@ func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return t.Transport.RoundTrip(req)
}
func randomChance(n int) bool {
return rand.Intn(n) == 0
}
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) {
if returnHTTPStatusOnly {
w.WriteHeader(http.StatusUnauthorized)
@@ -68,11 +47,6 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
lg := internal.GetRequestLogger(r)
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
lg.Error("client was given a challenge but does not in fact support gzip compression")
s.respondWithError(w, r, "Client Error: Please ensure your browser is up to date and try again later.")
}
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
var ogTags map[string]string = nil
@@ -84,13 +58,6 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
}
}
http.SetCookie(w, &http.Cookie{
Name: anubis.TestCookieName,
Value: challenge,
Expires: time.Now().Add(30 * time.Minute),
Path: "/",
})
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags)
if err != nil {
lg.Error("render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
@@ -98,10 +65,14 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
return
}
handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "challenges_issued"})
}
handler := internal.NoStoreCache(templ.Handler(
component,
templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
)))
))
handler.ServeHTTP(w, r)
}

View File

@@ -11,7 +11,7 @@ func TestClearCookie(t *testing.T) {
srv := spawnAnubis(t, Options{})
rw := httptest.NewRecorder()
srv.ClearCookie(rw, srv.cookieName, "/")
srv.ClearCookie(rw)
resp := rw.Result()
@@ -36,7 +36,7 @@ func TestClearCookieWithDomain(t *testing.T) {
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
rw := httptest.NewRecorder()
srv.ClearCookie(rw, srv.cookieName, "/")
srv.ClearCookie(rw)
resp := rw.Result()

View File

@@ -4,15 +4,15 @@ import (
"fmt"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
type Bot struct {
Rules checker.Impl
Rules Checker
Challenge *config.ChallengeRules
Name string
Action config.Rule
Weight *config.Weight
}
func (b Bot) Hash() string {

View File

@@ -9,7 +9,6 @@ import (
"strings"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/yl2chen/cidranger"
)
@@ -17,12 +16,43 @@ var (
ErrMisconfiguration = errors.New("[unexpected] policy: administrator misconfiguration")
)
type Checker interface {
Check(*http.Request) (bool, error)
Hash() string
}
type CheckerList []Checker
func (cl CheckerList) Check(r *http.Request) (bool, error) {
for _, c := range cl {
ok, err := c.Check(r)
if err != nil {
return ok, err
}
if ok {
return ok, nil
}
}
return false, nil
}
func (cl CheckerList) Hash() string {
var sb strings.Builder
for _, c := range cl {
fmt.Fprintln(&sb, c.Hash())
}
return internal.SHA256sum(sb.String())
}
type RemoteAddrChecker struct {
ranger cidranger.Ranger
hash string
}
func NewRemoteAddrChecker(cidrs []string) (checker.Impl, error) {
func NewRemoteAddrChecker(cidrs []string) (Checker, error) {
ranger := cidranger.NewPCTrieRanger()
var sb strings.Builder
@@ -75,11 +105,11 @@ type HeaderMatchesChecker struct {
hash string
}
func NewUserAgentChecker(rexStr string) (checker.Impl, error) {
func NewUserAgentChecker(rexStr string) (Checker, error) {
return NewHeaderMatchesChecker("User-Agent", rexStr)
}
func NewHeaderMatchesChecker(header, rexStr string) (checker.Impl, error) {
func NewHeaderMatchesChecker(header, rexStr string) (Checker, error) {
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
if err != nil {
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
@@ -104,7 +134,7 @@ type PathChecker struct {
hash string
}
func NewPathChecker(rexStr string) (checker.Impl, error) {
func NewPathChecker(rexStr string) (Checker, error) {
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
if err != nil {
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
@@ -124,7 +154,7 @@ func (pc *PathChecker) Hash() string {
return pc.hash
}
func NewHeaderExistsChecker(key string) checker.Impl {
func NewHeaderExistsChecker(key string) Checker {
return headerExistsChecker{strings.TrimSpace(key)}
}
@@ -144,8 +174,8 @@ func (hec headerExistsChecker) Hash() string {
return internal.SHA256sum(hec.header)
}
func NewHeadersChecker(headermap map[string]string) (checker.Impl, error) {
var result checker.List
func NewHeadersChecker(headermap map[string]string) (Checker, error) {
var result CheckerList
var errs []error
for key, rexStr := range headermap {

View File

@@ -1,41 +0,0 @@
// Package checker defines the Checker interface and a helper utility to avoid import cycles.
package checker
import (
"fmt"
"net/http"
"strings"
"github.com/TecharoHQ/anubis/internal"
)
type Impl interface {
Check(*http.Request) (bool, error)
Hash() string
}
type List []Impl
func (l List) Check(r *http.Request) (bool, error) {
for _, c := range l {
ok, err := c.Check(r)
if err != nil {
return ok, err
}
if ok {
return ok, nil
}
}
return false, nil
}
func (l List) Hash() string {
var sb strings.Builder
for _, c := range l {
fmt.Fprintln(&sb, c.Hash())
}
return internal.SHA256sum(sb.String())
}

View File

@@ -7,12 +7,15 @@ import (
)
type CheckResult struct {
Name string
Rule config.Rule
Name string
Rule config.Rule
Weight int
}
func (cr CheckResult) LogValue() slog.Value {
return slog.GroupValue(
slog.String("name", cr.Name),
slog.String("rule", string(cr.Rule)))
slog.String("rule", string(cr.Rule)),
slog.Int("weight", cr.Weight),
)
}

View File

@@ -1,44 +0,0 @@
package config
import (
"errors"
"fmt"
)
var (
ErrPrivateASN = errors.New("bot.ASNs: you have specified a private use ASN")
)
type ASNs struct {
Match []uint32 `json:"match"`
}
func (a *ASNs) Valid() error {
var errs []error
for _, asn := range a.Match {
if isPrivateASN(asn) {
errs = append(errs, fmt.Errorf("%w: %d is private (see RFC 6996)", ErrPrivateASN, asn))
}
}
if len(errs) != 0 {
return fmt.Errorf("bot.ASNs: invalid ASN settings: %w", errors.Join(errs...))
}
return nil
}
// isPrivateASN checks if an ASN is in the private use area.
//
// Based on RFC 6996 and IANA allocations.
func isPrivateASN(asn uint32) bool {
switch {
case asn >= 64512 && asn <= 65534:
return true
case asn >= 4200000000 && asn <= 4294967294:
return true
default:
return false
}
}

View File

@@ -39,6 +39,7 @@ const (
RuleAllow Rule = "ALLOW"
RuleDeny Rule = "DENY"
RuleChallenge Rule = "CHALLENGE"
RuleWeigh Rule = "WEIGH"
RuleBenchmark Rule = "DEBUG_BENCHMARK"
)
@@ -56,8 +57,7 @@ type BotConfig struct {
HeadersRegex map[string]string `json:"headers_regex,omitempty"`
Expression *ExpressionOrList `json:"expression,omitempty"`
Challenge *ChallengeRules `json:"challenge,omitempty"`
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
Weight *Weight `json:"weight,omitempty"`
Name string `json:"name"`
Action Rule `json:"action"`
RemoteAddr []string `json:"remote_addresses,omitempty"`
@@ -91,9 +91,7 @@ func (b BotConfig) Valid() error {
allFieldsEmpty := b.UserAgentRegex == nil &&
b.PathRegex == nil &&
len(b.RemoteAddr) == 0 &&
len(b.HeadersRegex) == 0 &&
b.ASNs == nil &&
b.GeoIP == nil
len(b.HeadersRegex) == 0
if allFieldsEmpty && b.Expression == nil {
errs = append(errs, ErrBotMustHaveUserAgentOrPath)
@@ -154,7 +152,7 @@ func (b BotConfig) Valid() error {
}
switch b.Action {
case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny:
case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny, RuleWeigh:
// okay
default:
errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action))
@@ -166,6 +164,10 @@ func (b BotConfig) Valid() error {
}
}
if b.Action == RuleWeigh && b.Weight == nil {
b.Weight = &Weight{Adjust: 5}
}
if len(errs) != 0 {
return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...))
}

View File

@@ -182,6 +182,25 @@ func TestBotValid(t *testing.T) {
},
err: nil,
},
{
name: "weight rule without weight",
bot: BotConfig{
Name: "weight-adjust-if-mozilla",
Action: RuleWeigh,
UserAgentRegex: p("Mozilla"),
},
},
{
name: "weight rule with weight adjust",
bot: BotConfig{
Name: "weight-adjust-if-mozilla",
Action: RuleWeigh,
UserAgentRegex: p("Mozilla"),
Weight: &Weight{
Adjust: 5,
},
},
},
}
for _, cs := range tests {

View File

@@ -14,8 +14,8 @@ var (
type ExpressionOrList struct {
Expression string `json:"-"`
All []string `json:"all"`
Any []string `json:"any"`
All []string `json:"all,omitempty"`
Any []string `json:"any,omitempty"`
}
func (eol ExpressionOrList) Equal(rhs *ExpressionOrList) bool {

View File

@@ -1,36 +0,0 @@
package config
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
countryCodeRegexp = regexp.MustCompile(`^\w{2}$`)
ErrNotCountryCode = errors.New("config.Bot: invalid country code")
)
type GeoIP struct {
Countries []string `json:"countries"`
}
func (g *GeoIP) Valid() error {
var errs []error
for i, cc := range g.Countries {
if !countryCodeRegexp.MatchString(cc) {
errs = append(errs, fmt.Errorf("%w: %s", ErrNotCountryCode, cc))
}
g.Countries[i] = strings.ToLower(cc)
}
if len(errs) != 0 {
return fmt.Errorf("bot.GeoIP: invalid GeoIP settings: %w", errors.Join(errs...))
}
return nil
}

View File

@@ -1,33 +0,0 @@
package config
import (
"errors"
"testing"
)
func TestGeoIPValid(t *testing.T) {
for _, cs := range []struct {
name string
countries []string
err error
}{
{
name: "basic-working",
countries: []string{"US", "Ca", "mx"},
err: nil,
},
} {
t.Run(cs.name, func(t *testing.T) {
g := &GeoIP{
Countries: cs.countries,
}
err := g.Valid()
if !errors.Is(err, cs.err) {
t.Fatalf("wanted error %v but got: %v", cs.err, err)
}
if err == nil && cs.err != nil {
t.Fatalf("wanted error %v but got none", cs.err)
}
})
}
}

View File

@@ -1,6 +0,0 @@
bots:
- name: challenge-cloudflare
action: CHALLENGE
asns:
match:
- 13335 # Cloudflare

View File

@@ -1,6 +0,0 @@
bots:
- name: compute-tarrif-us
action: CHALLENGE
geoip:
countries:
- US

View File

@@ -0,0 +1,6 @@
bots:
- name: simple-weight-adjust
action: WEIGH
user_agent_regex: Mozilla
weight:
adjust: 5

View File

@@ -0,0 +1,4 @@
bots:
- name: weight
action: WEIGH
user_agent_regex: Mozilla

View File

@@ -0,0 +1,5 @@
package config
type Weight struct {
Adjust int `json:"adjust"`
}

View File

@@ -0,0 +1,47 @@
package policy
import (
"fmt"
"net/http"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/store/valkey"
)
type PassRateChecker struct {
store *valkey.Store
header string
rate float64
}
func NewPassRateChecker(store *valkey.Store, rate float64) Checker {
return &PassRateChecker{
store: store,
rate: rate,
header: "User-Agent",
}
}
func (prc *PassRateChecker) Hash() string {
return internal.SHA256sum(fmt.Sprintf("pass rate checker::%s", prc.header))
}
func (prc *PassRateChecker) Check(r *http.Request) (bool, error) {
data, err := prc.store.MultiGetInt(r.Context(), [][]string{
{"pass_rate", prc.header, r.Header.Get(prc.header), "pass"},
{"pass_rate", prc.header, r.Header.Get(prc.header), "challenges_issued"},
{"pass_rate", prc.header, r.Header.Get(prc.header), "fail"},
})
if err != nil {
return false, err
}
passCount, challengeCount, failCount := data[0], data[1], data[2]
passRate := float64(passCount-failCount) / float64(challengeCount)
if passRate >= prc.rate {
return true, nil
}
return false, nil
}

View File

@@ -1,7 +1,6 @@
package policy
import (
"context"
"errors"
"fmt"
"io"
@@ -9,8 +8,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/TecharoHQ/anubis/internal/thoth"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
@@ -19,8 +16,6 @@ var (
Name: "anubis_policy_results",
Help: "The results of each policy rule",
}, []string{"rule", "action"})
ErrNoThothClient = errors.New("config: you have specified Thoth related checks but have no active Thoth client")
)
type ParsedConfig struct {
@@ -39,7 +34,7 @@ func NewParsedConfig(orig *config.Config) *ParsedConfig {
}
}
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
c, err := config.Load(fin, fname)
if err != nil {
return nil, err
@@ -47,8 +42,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
var validationErrs []error
tc, hasThothClient := thoth.FromContext(ctx)
result := NewParsedConfig(c)
result.DefaultDifficulty = defaultDifficulty
@@ -63,7 +56,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
Action: b.Action,
}
cl := checker.List{}
cl := CheckerList{}
if len(b.RemoteAddr) > 0 {
c, err := NewRemoteAddrChecker(b.RemoteAddr)
@@ -110,24 +103,6 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
}
if b.ASNs != nil {
if !hasThothClient {
validationErrs = append(validationErrs, fmt.Errorf("%w: %w", ErrMisconfiguration, ErrNoThothClient))
continue
}
cl = append(cl, tc.ASNCheckerFor(b.ASNs.Match))
}
if b.GeoIP != nil {
if !hasThothClient {
validationErrs = append(validationErrs, fmt.Errorf("%w: %w", ErrMisconfiguration, ErrNoThothClient))
continue
}
cl = append(cl, tc.GeoIPCheckerFor(b.GeoIP.Countries))
}
if b.Challenge == nil {
parsedBot.Challenge = &config.ChallengeRules{
Difficulty: defaultDifficulty,
@@ -141,6 +116,10 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
}
}
if b.Weight != nil {
parsedBot.Weight = b.Weight
}
parsedBot.Rules = cl
result.Bots = append(result.Bots, parsedBot)

View File

@@ -7,8 +7,6 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal/thoth"
"github.com/TecharoHQ/anubis/internal/thoth/thothmock"
)
func TestDefaultPolicyMustParse(t *testing.T) {
@@ -18,11 +16,7 @@ func TestDefaultPolicyMustParse(t *testing.T) {
}
defer fin.Close()
thothCli := &thoth.Client{}
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
ctx := thoth.With(t.Context(), thothCli)
if _, err := ParseConfig(ctx, fin, "botPolicies.json", anubis.DefaultDifficulty); err != nil {
if _, err := ParseConfig(fin, "botPolicies.json", anubis.DefaultDifficulty); err != nil {
t.Fatalf("can't parse config: %v", err)
}
}
@@ -42,11 +36,7 @@ func TestGoodConfigs(t *testing.T) {
}
defer fin.Close()
thothCli := &thoth.Client{}
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
ctx := thoth.With(t.Context(), thothCli)
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
t.Fatal(err)
}
})
@@ -68,11 +58,7 @@ func TestBadConfigs(t *testing.T) {
}
defer fin.Close()
thothCli := &thoth.Client{}
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
ctx := thoth.With(t.Context(), thothCli)
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
t.Fatal(err)
} else {
t.Log(err)

View File

@@ -1,8 +1,4 @@
bots:
- name: cloudflare-workers
expression: '"Cf-Worker" in headers'
action: DENY
status_codes:
CHALLENGE: 401
DENY: 403
action: DENY

View File

@@ -2,8 +2,4 @@ bots:
- name: cloudflare-workers
headers_regex:
CF-Worker: .*
action: DENY
status_codes:
CHALLENGE: 401
DENY: 403
action: DENY

View File

@@ -10,7 +10,7 @@
"test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker",
"assets": "go generate ./... && ./web/build.sh && ./xess/build.sh",
"build": "npm run assets && go build -o ./var/anubis ./cmd/anubis",
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000",
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address",
"container": "npm run assets && go run ./cmd/containerbuild",
"package": "yeet",
"lint": "make lint"
@@ -27,4 +27,4 @@
"postcss-import-url": "^7.2.0",
"postcss-url": "^10.1.3"
}
}
}

View File

@@ -12,8 +12,6 @@ CacheDirectory=anubis/%i
CacheDirectoryMode=0755
StateDirectory=anubis/%i
StateDirectoryMode=0755
RuntimeDirectory=anubis
RuntimeDirectoryMode=0755
ReadWritePaths=/run
[Install]

View File

@@ -46,7 +46,6 @@ User-agent: PanguBot
User-agent: Perplexity-User
User-agent: PerplexityBot
User-agent: PetalBot
User-agent: QualifiedBot
User-agent: Scrapy
User-agent: SemrushBot-OCOB
User-agent: SemrushBot-SWA