mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-09 18:18:49 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ecf0a6de | ||
|
|
502640bb2f | ||
|
|
86ee5697f3 | ||
|
|
9bb38d6ad0 | ||
|
|
49ab76c9dd | ||
|
|
4aea22fac5 | ||
|
|
86ad85909c | ||
|
|
315253dce7 | ||
|
|
946557b378 | ||
|
|
5e7bfa5ec2 | ||
|
|
7b8953303d | ||
|
|
a6045d6698 | ||
|
|
e31e1ca5e7 | ||
|
|
50e030d17e | ||
|
|
b640c567da | ||
|
|
9e9982ab5d | ||
|
|
3b98368aa9 | ||
|
|
76849531cd | ||
|
|
961320540b | ||
|
|
91c21fbb4b | ||
|
|
caf69be97b |
29
.github/actions/spelling/expect.txt
vendored
29
.github/actions/spelling/expect.txt
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
acs
|
||||||
aeacus
|
aeacus
|
||||||
Aibrew
|
Aibrew
|
||||||
alrest
|
alrest
|
||||||
@@ -6,6 +7,9 @@ anthro
|
|||||||
anubis
|
anubis
|
||||||
anubistest
|
anubistest
|
||||||
archlinux
|
archlinux
|
||||||
|
asnc
|
||||||
|
asnchecker
|
||||||
|
asns
|
||||||
badregexes
|
badregexes
|
||||||
berr
|
berr
|
||||||
bingbot
|
bingbot
|
||||||
@@ -18,6 +22,7 @@ botnet
|
|||||||
BPort
|
BPort
|
||||||
broked
|
broked
|
||||||
cachebuster
|
cachebuster
|
||||||
|
cachediptoasn
|
||||||
Caddyfile
|
Caddyfile
|
||||||
caninetools
|
caninetools
|
||||||
Cardyb
|
Cardyb
|
||||||
@@ -71,15 +76,22 @@ Fordola
|
|||||||
forgejo
|
forgejo
|
||||||
fsys
|
fsys
|
||||||
fullchain
|
fullchain
|
||||||
|
gaissmai
|
||||||
Galvus
|
Galvus
|
||||||
|
geoip
|
||||||
|
geoipchecker
|
||||||
gha
|
gha
|
||||||
|
gipc
|
||||||
gitea
|
gitea
|
||||||
|
godotenv
|
||||||
goland
|
goland
|
||||||
gomod
|
gomod
|
||||||
goodbot
|
goodbot
|
||||||
googlebot
|
googlebot
|
||||||
govulncheck
|
govulncheck
|
||||||
GPG
|
GPG
|
||||||
|
grpcprom
|
||||||
|
grw
|
||||||
Hashcash
|
Hashcash
|
||||||
hashrate
|
hashrate
|
||||||
headermap
|
headermap
|
||||||
@@ -87,13 +99,18 @@ healthcheck
|
|||||||
hec
|
hec
|
||||||
hmc
|
hmc
|
||||||
hostable
|
hostable
|
||||||
|
htmx
|
||||||
httpdebug
|
httpdebug
|
||||||
|
hypertext
|
||||||
iat
|
iat
|
||||||
ifm
|
ifm
|
||||||
inp
|
inp
|
||||||
|
IPTo
|
||||||
|
iptoasn
|
||||||
iss
|
iss
|
||||||
ivh
|
ivh
|
||||||
JGit
|
JGit
|
||||||
|
joho
|
||||||
journalctl
|
journalctl
|
||||||
jshelter
|
jshelter
|
||||||
JWTs
|
JWTs
|
||||||
@@ -110,11 +127,13 @@ lgbt
|
|||||||
licend
|
licend
|
||||||
licstart
|
licstart
|
||||||
lightpanda
|
lightpanda
|
||||||
|
LIMSA
|
||||||
Linting
|
Linting
|
||||||
linuxbrew
|
linuxbrew
|
||||||
LLU
|
LLU
|
||||||
loadbalancer
|
loadbalancer
|
||||||
lol
|
lol
|
||||||
|
LOMINSA
|
||||||
maintainership
|
maintainership
|
||||||
malware
|
malware
|
||||||
mcr
|
mcr
|
||||||
@@ -149,6 +168,7 @@ promauto
|
|||||||
promhttp
|
promhttp
|
||||||
pwcmd
|
pwcmd
|
||||||
pwuser
|
pwuser
|
||||||
|
qualys
|
||||||
qwant
|
qwant
|
||||||
qwantbot
|
qwantbot
|
||||||
rac
|
rac
|
||||||
@@ -162,12 +182,15 @@ risc
|
|||||||
ruleset
|
ruleset
|
||||||
RUnlock
|
RUnlock
|
||||||
sas
|
sas
|
||||||
|
sasl
|
||||||
Scumm
|
Scumm
|
||||||
|
searx
|
||||||
sebest
|
sebest
|
||||||
secretplans
|
secretplans
|
||||||
selfsigned
|
selfsigned
|
||||||
setsebool
|
setsebool
|
||||||
sitemap
|
sitemap
|
||||||
|
sls
|
||||||
Sourceware
|
Sourceware
|
||||||
Spambot
|
Spambot
|
||||||
sparkline
|
sparkline
|
||||||
@@ -180,11 +203,14 @@ subr
|
|||||||
subrequest
|
subrequest
|
||||||
tagline
|
tagline
|
||||||
tarballs
|
tarballs
|
||||||
|
tarrif
|
||||||
techaro
|
techaro
|
||||||
techarohq
|
techarohq
|
||||||
templ
|
templ
|
||||||
templruntime
|
templruntime
|
||||||
testarea
|
testarea
|
||||||
|
thoth
|
||||||
|
thothmock
|
||||||
torproject
|
torproject
|
||||||
traefik
|
traefik
|
||||||
unixhttpd
|
unixhttpd
|
||||||
@@ -200,7 +226,7 @@ webmaster
|
|||||||
webpage
|
webpage
|
||||||
websecure
|
websecure
|
||||||
websites
|
websites
|
||||||
workaround
|
Workaround
|
||||||
workdir
|
workdir
|
||||||
xcaddy
|
xcaddy
|
||||||
Xeact
|
Xeact
|
||||||
@@ -210,6 +236,7 @@ xesite
|
|||||||
xess
|
xess
|
||||||
xff
|
xff
|
||||||
XForwarded
|
XForwarded
|
||||||
|
XNG
|
||||||
XReal
|
XReal
|
||||||
yae
|
yae
|
||||||
YAMLTo
|
YAMLTo
|
||||||
|
|||||||
14
.github/workflows/docs-deploy.yml
vendored
14
.github/workflows/docs-deploy.yml
vendored
@@ -3,7 +3,7 @@ name: Docs deploy
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ["main"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
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
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||||
with:
|
with:
|
||||||
context: ./docs
|
context: ./docs
|
||||||
cache-to: type=gha
|
cache-to: type=gha
|
||||||
@@ -50,15 +50,15 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
|
|
||||||
- name: Apply k8s manifests to aeacus
|
- name: Apply k8s manifests to aeacus
|
||||||
uses: actions-hub/kubectl@e81783053d902f50d752d21a6d99cf9689a652e1 # v1.33.0
|
uses: actions-hub/kubectl@f632a31512a74cb35940627c49c20f67723cbaaf # v1.33.1
|
||||||
env:
|
env:
|
||||||
KUBE_CONFIG: ${{ secrets.AEACUS_KUBECONFIG }}
|
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
|
||||||
with:
|
with:
|
||||||
args: apply -k docs/manifest
|
args: apply -k docs/manifest
|
||||||
|
|
||||||
- name: Apply k8s manifests to aeacus
|
- name: Apply k8s manifests to aeacus
|
||||||
uses: actions-hub/kubectl@e81783053d902f50d752d21a6d99cf9689a652e1 # v1.33.0
|
uses: actions-hub/kubectl@f632a31512a74cb35940627c49c20f67723cbaaf # v1.33.1
|
||||||
env:
|
env:
|
||||||
KUBE_CONFIG: ${{ secrets.AEACUS_KUBECONFIG }}
|
KUBE_CONFIG: ${{ secrets.LIMSA_LOMINSA_KUBECONFIG }}
|
||||||
with:
|
with:
|
||||||
args: rollout restart -n default deploy/anubis-docs
|
args: rollout restart -n default deploy/anubis-docs
|
||||||
|
|||||||
2
.github/workflows/docs-test.yml
vendored
2
.github/workflows/docs-test.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||||
with:
|
with:
|
||||||
context: ./docs
|
context: ./docs
|
||||||
cache-to: type=gha
|
cache-to: type=gha
|
||||||
|
|||||||
2
.github/workflows/spelling.yml
vendored
2
.github/workflows/spelling.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: check-spelling
|
- name: check-spelling
|
||||||
id: spelling
|
id: spelling
|
||||||
uses: check-spelling/check-spelling@67debf50669c7fc76fc8f5d7f996384535a72b77 # v0.0.24
|
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
|
||||||
with:
|
with:
|
||||||
suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}
|
suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}
|
||||||
checkout: true
|
checkout: true
|
||||||
|
|||||||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Upload SARIF file
|
- name: Upload SARIF file
|
||||||
uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
|
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
category: zizmor
|
category: zizmor
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const CookieName = "techaro.lol-anubis-auth"
|
|||||||
// WithDomainCookieName is the name that is prepended to the per-domain cookie used when COOKIE_DOMAIN is set.
|
// 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 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.
|
// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
|
||||||
const CookieDefaultExpirationTime = 7 * 24 * time.Hour
|
const CookieDefaultExpirationTime = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ import (
|
|||||||
"github.com/TecharoHQ/anubis"
|
"github.com/TecharoHQ/anubis"
|
||||||
"github.com/TecharoHQ/anubis/data"
|
"github.com/TecharoHQ/anubis/data"
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/TecharoHQ/anubis/internal/thoth"
|
||||||
libanubis "github.com/TecharoHQ/anubis/lib"
|
libanubis "github.com/TecharoHQ/anubis/lib"
|
||||||
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
|
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
|
||||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||||
"github.com/TecharoHQ/anubis/web"
|
"github.com/TecharoHQ/anubis/web"
|
||||||
"github.com/facebookgo/flagenv"
|
"github.com/facebookgo/flagenv"
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ 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.")
|
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)")
|
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")
|
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")
|
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")
|
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")
|
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")
|
||||||
@@ -65,6 +68,9 @@ var (
|
|||||||
ogCacheConsiderHost = flag.Bool("og-cache-consider-host", false, "enable or disable the use of the host in the Open Graph tag cache")
|
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")
|
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")
|
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
func keyFromHex(value string) (ed25519.PrivateKey, error) {
|
func keyFromHex(value string) (ed25519.PrivateKey, error) {
|
||||||
@@ -135,7 +141,7 @@ func setupListener(network string, address string) (net.Listener, string) {
|
|||||||
return listener, formattedAddress
|
return listener, formattedAddress
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeReverseProxy(target string, insecureSkipVerify bool) (http.Handler, error) {
|
func makeReverseProxy(target string, targetHost string, insecureSkipVerify bool) (http.Handler, error) {
|
||||||
targetUri, err := url.Parse(target)
|
targetUri, err := url.Parse(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||||
@@ -167,6 +173,14 @@ func makeReverseProxy(target string, insecureSkipVerify bool) (http.Handler, err
|
|||||||
rp := httputil.NewSingleHostReverseProxy(targetUri)
|
rp := httputil.NewSingleHostReverseProxy(targetUri)
|
||||||
rp.Transport = transport
|
rp.Transport = transport
|
||||||
|
|
||||||
|
if targetHost != "" {
|
||||||
|
originalDirector := rp.Director
|
||||||
|
rp.Director = func(req *http.Request) {
|
||||||
|
originalDirector(req)
|
||||||
|
req.Host = targetHost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rp, nil
|
return rp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,13 +219,25 @@ 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
|
// 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) != "" {
|
if strings.TrimSpace(*target) != "" {
|
||||||
var err error
|
var err error
|
||||||
rp, err = makeReverseProxy(*target, *targetInsecureSkipVerify)
|
rp, err = makeReverseProxy(*target, *targetHost, *targetInsecureSkipVerify)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("can't make reverse proxy: %v", err)
|
log.Fatalf("can't make reverse proxy: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, *challengeDifficulty)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("can't parse policy file: %v", err)
|
log.Fatalf("can't parse policy file: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
20
data/apps/bookstack-saml.yaml
Normal file
20
data/apps/bookstack-saml.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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")
|
||||||
7
data/apps/qualys-ssl-labs.yml
Normal file
7
data/apps/qualys-ssl-labs.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# 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
|
||||||
9
data/apps/searx-checker.yml
Normal file
9
data/apps/searx-checker.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
- name: "ai-robots-txt"
|
- name: "ai-robots-txt"
|
||||||
user_agent_regex: >-
|
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|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|QualifiedBot|Scrapy|SemrushBot-OCOB|SemrushBot-SWA|Sidetrade indexer bot|TikTokSpider|Timpibot|VelenPublicWebCrawler|Webzio-Extended|YouBot
|
||||||
action: DENY
|
action: DENY
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 `check-spelling` for spell checking
|
||||||
- Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend
|
- 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.
|
- Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration.
|
||||||
@@ -18,6 +20,13 @@ 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
|
- Updated the nonce value in the challenge JWT cookie to be a string instead of a number
|
||||||
- Rename cookies in response to user feedback
|
- Rename cookies in response to user feedback
|
||||||
- Ensure cookie renaming is consistent across configuration options
|
- 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
|
## v1.18.0: Varis zos Galvus
|
||||||
|
|
||||||
@@ -43,7 +52,7 @@ Or as complicated as:
|
|||||||
expression:
|
expression:
|
||||||
all:
|
all:
|
||||||
- >-
|
- >-
|
||||||
(
|
(
|
||||||
userAgent.startsWith("git/") ||
|
userAgent.startsWith("git/") ||
|
||||||
userAgent.contains("libgit") ||
|
userAgent.contains("libgit") ||
|
||||||
userAgent.startsWith("go-git") ||
|
userAgent.startsWith("go-git") ||
|
||||||
|
|||||||
8
docs/docs/admin/frameworks/_category_.json
Normal file
8
docs/docs/admin/frameworks/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"label": "Frameworks",
|
||||||
|
"position": 30,
|
||||||
|
"link": {
|
||||||
|
"type": "generated-index",
|
||||||
|
"description": "Information about getting specific frameworks or tools working with Anubis."
|
||||||
|
}
|
||||||
|
}
|
||||||
45
docs/docs/admin/frameworks/htmx.mdx
Normal file
45
docs/docs/admin/frameworks/htmx.mdx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 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.
|
||||||
@@ -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. |
|
| `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` | `: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. |
|
| `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. |
|
| `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_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. |
|
| `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. |
|
| `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. |
|
| `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_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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
| `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. |
|
| `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. |
|
| `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`. |
|
| `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,6 +84,7 @@ If you don't know or understand what these settings mean, ignore them. These are
|
|||||||
|
|
||||||
| Environment Variable | Default value | Explanation |
|
| 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. |
|
| `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>
|
</details>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ This page contains a non-exhaustive list with all websites using Anubis.
|
|||||||
- https://bugzilla.proxmox.com
|
- https://bugzilla.proxmox.com
|
||||||
- https://hofstede.io/
|
- https://hofstede.io/
|
||||||
- https://www.indiemag.fr/
|
- https://www.indiemag.fr/
|
||||||
|
- https://reddit.nerdvpn.de/
|
||||||
- <details>
|
- <details>
|
||||||
<summary>FreeCAD</summary>
|
<summary>FreeCAD</summary>
|
||||||
- https://forum.freecad.org/
|
- https://forum.freecad.org/
|
||||||
|
|||||||
15
go.mod
15
go.mod
@@ -3,20 +3,26 @@ module github.com/TecharoHQ/anubis
|
|||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/TecharoHQ/thoth-proto v0.2.0
|
||||||
github.com/a-h/templ v0.3.865
|
github.com/a-h/templ v0.3.865
|
||||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
|
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/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/google/cel-go v0.25.0
|
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/playwright-community/playwright-go v0.5200.0
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.22.0
|
||||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
|
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
|
||||||
github.com/yl2chen/cidranger v1.0.2
|
github.com/yl2chen/cidranger v1.0.2
|
||||||
golang.org/x/net v0.40.0
|
golang.org/x/net v0.40.0
|
||||||
|
google.golang.org/grpc v1.72.1
|
||||||
k8s.io/apimachinery v0.33.0
|
k8s.io/apimachinery v0.33.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
al.essio.dev/pkg/shellescape v1.6.0 // indirect
|
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
|
cel.dev/expr v0.23.1 // indirect
|
||||||
dario.cat/mergo v1.0.1 // indirect
|
dario.cat/mergo v1.0.1 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
@@ -64,6 +70,7 @@ require (
|
|||||||
github.com/goreleaser/chglog v0.7.0 // indirect
|
github.com/goreleaser/chglog v0.7.0 // indirect
|
||||||
github.com/goreleaser/fileglob v1.3.0 // indirect
|
github.com/goreleaser/fileglob v1.3.0 // indirect
|
||||||
github.com/goreleaser/nfpm/v2 v2.42.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/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
@@ -84,7 +91,7 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||||
github.com/spf13/cast v1.7.1 // indirect
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
|
gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
|
||||||
@@ -99,9 +106,9 @@ require (
|
|||||||
golang.org/x/tools v0.32.0 // indirect
|
golang.org/x/tools v0.32.0 // indirect
|
||||||
golang.org/x/vuln v1.1.4 // indirect
|
golang.org/x/vuln v1.1.4 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
|
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
honnef.co/go/tools v0.6.1 // indirect
|
honnef.co/go/tools v0.6.1 // indirect
|
||||||
|
|||||||
54
go.sum
54
go.sum
@@ -1,5 +1,7 @@
|
|||||||
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
|
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
|
||||||
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
|
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 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
|
||||||
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
@@ -28,6 +30,8 @@ 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/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 h1:pX2++u4KUq+K2k/ZCzGXLtkD3ceCqIdi0tDyb+IbSyo=
|
||||||
github.com/Songmu/gitconfig v0.2.0/go.mod h1:cB5bYJer+pl7W8g6RHFwL/0X6aJROVrYuHlvc7PT+hE=
|
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 h1:Pcsnq5HTnk4Xntlu/FNEidH7x55bIx+f5Mk1hpVIngs=
|
||||||
github.com/TecharoHQ/yeet v0.2.3/go.mod h1:avLiwxZpNY37A/o35XledvdmGnTkm3G7+Oskxca6Z7Y=
|
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=
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||||
@@ -97,6 +101,8 @@ 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/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 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
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=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
@@ -109,6 +115,10 @@ 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-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 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-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/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 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||||
@@ -130,6 +140,8 @@ 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-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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
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 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
|
||||||
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
|
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=
|
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=
|
||||||
@@ -155,12 +167,18 @@ github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+
|
|||||||
github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU=
|
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 h1:7BW4WQWyvZDrT0C7SyWop+J8rtqFyTB17Sb2/j/NxMI=
|
||||||
github.com/goreleaser/nfpm/v2 v2.42.0/go.mod h1:DtNL+nKpfB8sMFZp+X7Xu3W64atyZYtTnYe8O925/mg=
|
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/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/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 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
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 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
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 h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
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=
|
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||||
@@ -244,13 +262,17 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS
|
|||||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
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 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
|
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
|
||||||
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
|
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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=
|
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
|
||||||
@@ -265,6 +287,18 @@ github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
|
||||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
|
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-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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
@@ -349,12 +383,14 @@ 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-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 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
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-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
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=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
35
internal/gzip.go
Normal file
35
internal/gzip.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
39
internal/thoth/asnchecker.go
Normal file
39
internal/thoth/asnchecker.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
81
internal/thoth/asnchecker_test.go
Normal file
81
internal/thoth/asnchecker_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
internal/thoth/auth.go
Normal file
39
internal/thoth/auth.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/thoth/cachediptoasn.go
Normal file
84
internal/thoth/cachediptoasn.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
14
internal/thoth/context.go
Normal file
14
internal/thoth/context.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
40
internal/thoth/geoipchecker.go
Normal file
40
internal/thoth/geoipchecker.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
63
internal/thoth/geoipchecker_test.go
Normal file
63
internal/thoth/geoipchecker_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/thoth/thoth.go
Normal file
114
internal/thoth/thoth.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/thoth/thoth_test.go
Normal file
29
internal/thoth/thoth_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/thoth/thothmock/iptoasn.go
Normal file
44
internal/thoth/thothmock/iptoasn.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -96,6 +96,12 @@ func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request)
|
|||||||
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
|
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
|
||||||
lg := internal.GetRequestLogger(r)
|
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)
|
cr, rule, err := s.check(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Error("check failed", "err", err)
|
lg.Error("check failed", "err", err)
|
||||||
@@ -121,21 +127,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
|
|||||||
ckie, err := r.Cookie(s.cookieName)
|
ckie, err := r.Cookie(s.cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ckie.Valid(); err != nil {
|
if err := ckie.Valid(); err != nil {
|
||||||
lg.Debug("cookie is invalid", "err", err)
|
lg.Debug("cookie is invalid", "err", err)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,7 +152,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
|
|||||||
|
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -156,13 +162,19 @@ 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 {
|
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 {
|
switch cr.Rule {
|
||||||
case config.RuleAllow:
|
case config.RuleAllow:
|
||||||
lg.Debug("allowing traffic to origin (explicit)")
|
lg.Debug("allowing traffic to origin (explicit)")
|
||||||
s.ServeHTTPNext(w, r)
|
s.ServeHTTPNext(w, r)
|
||||||
return true
|
return true
|
||||||
case config.RuleDeny:
|
case config.RuleDeny:
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Info("explicit deny")
|
lg.Info("explicit deny")
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
lg.Error("rule is nil, cannot calculate checksum")
|
lg.Error("rule is nil, cannot calculate checksum")
|
||||||
@@ -181,7 +193,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
|
|||||||
s.RenderBench(w, r)
|
s.RenderBench(w, r)
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
|
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\"")
|
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"")
|
||||||
return true
|
return true
|
||||||
@@ -233,6 +245,8 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
lg = lg.With("check_result", cr)
|
lg = lg.With("check_result", cr)
|
||||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||||
|
|
||||||
|
s.SetCookie(w, anubis.TestCookieName, challenge, "/")
|
||||||
|
|
||||||
err = encoder.Encode(struct {
|
err = encoder.Encode(struct {
|
||||||
Rules *config.ChallengeRules `json:"rules"`
|
Rules *config.ChallengeRules `json:"rules"`
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
@@ -252,6 +266,14 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||||
lg := internal.GetRequestLogger(r)
|
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")
|
redir := r.FormValue("redir")
|
||||||
redirURL, err := url.ParseRequestURI(redir)
|
redirURL, err := url.ParseRequestURI(redir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,14 +287,14 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
cr, rule, err := s.check(r)
|
cr, rule, err := s.check(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Error("check failed", "err", err)
|
lg.Error("check failed", "err", err)
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
lg = lg.With("check_result", cr)
|
lg = lg.With("check_result", cr)
|
||||||
|
|
||||||
nonceStr := r.FormValue("nonce")
|
nonceStr := r.FormValue("nonce")
|
||||||
if nonceStr == "" {
|
if nonceStr == "" {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Debug("no nonce")
|
lg.Debug("no nonce")
|
||||||
s.respondWithError(w, r, "missing nonce")
|
s.respondWithError(w, r, "missing nonce")
|
||||||
return
|
return
|
||||||
@@ -280,7 +302,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
elapsedTimeStr := r.FormValue("elapsedTime")
|
elapsedTimeStr := r.FormValue("elapsedTime")
|
||||||
if elapsedTimeStr == "" {
|
if elapsedTimeStr == "" {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Debug("no elapsedTime")
|
lg.Debug("no elapsedTime")
|
||||||
s.respondWithError(w, r, "missing elapsedTime")
|
s.respondWithError(w, r, "missing elapsedTime")
|
||||||
return
|
return
|
||||||
@@ -288,7 +310,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Debug("elapsedTime doesn't parse", "err", err)
|
lg.Debug("elapsedTime doesn't parse", "err", err)
|
||||||
s.respondWithError(w, r, "invalid elapsedTime")
|
s.respondWithError(w, r, "invalid elapsedTime")
|
||||||
return
|
return
|
||||||
@@ -310,11 +332,19 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
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)
|
nonce, err := strconv.Atoi(nonceStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Debug("nonce doesn't parse", "err", err)
|
lg.Debug("nonce doesn't parse", "err", err)
|
||||||
s.respondWithError(w, r, "invalid nonce")
|
s.respondWithError(w, r, "invalid response")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +352,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
calculated := internal.SHA256sum(calcString)
|
calculated := internal.SHA256sum(calcString)
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Debug("hash does not match", "got", response, "want", calculated)
|
lg.Debug("hash does not match", "got", response, "want", calculated)
|
||||||
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
||||||
failedValidations.Inc()
|
failedValidations.Inc()
|
||||||
@@ -331,18 +361,13 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// compare the leading zeroes
|
// compare the leading zeroes
|
||||||
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
|
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
|
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
|
||||||
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
|
||||||
failedValidations.Inc()
|
failedValidations.Inc()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust cookie path if base prefix is not empty
|
|
||||||
cookiePath := "/"
|
|
||||||
if anubis.BasePrefix != "" {
|
|
||||||
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
|
|
||||||
}
|
|
||||||
// generate JWT cookie
|
// generate JWT cookie
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
|
||||||
"challenge": challenge,
|
"challenge": challenge,
|
||||||
@@ -355,20 +380,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokenString, err := token.SignedString(s.priv)
|
tokenString, err := token.SignedString(s.priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lg.Error("failed to sign JWT", "err", err)
|
lg.Error("failed to sign JWT", "err", err)
|
||||||
s.ClearCookie(w)
|
s.ClearCookie(w, s.cookieName, cookiePath)
|
||||||
s.respondWithError(w, r, "failed to sign JWT")
|
s.respondWithError(w, r, "failed to sign JWT")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
s.SetCookie(w, s.cookieName, tokenString, cookiePath)
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
challengesValidated.Inc()
|
challengesValidated.Inc()
|
||||||
lg.Debug("challenge passed, redirecting to app")
|
lg.Debug("challenge passed, redirecting to app")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,6 +14,8 @@ import (
|
|||||||
"github.com/TecharoHQ/anubis"
|
"github.com/TecharoHQ/anubis"
|
||||||
"github.com/TecharoHQ/anubis/data"
|
"github.com/TecharoHQ/anubis/data"
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"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"
|
||||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||||
)
|
)
|
||||||
@@ -20,7 +23,11 @@ import (
|
|||||||
func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
|
func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
anubisPolicy, err := LoadPoliciesOrDefault(fname, anubis.DefaultDifficulty)
|
thothCli := &thoth.Client{}
|
||||||
|
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
|
||||||
|
ctx := thoth.With(t.Context(), thothCli)
|
||||||
|
|
||||||
|
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, anubis.DefaultDifficulty)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -43,10 +50,10 @@ type challenge struct {
|
|||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
|
func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challenge {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
resp, err := ts.Client().Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("can't request challenge: %v", err)
|
t.Fatalf("can't request challenge: %v", err)
|
||||||
}
|
}
|
||||||
@@ -60,109 +67,8 @@ func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
|
|||||||
return chall
|
return chall
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadPolicies(t *testing.T) {
|
func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challenge) *http.Response {
|
||||||
for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} {
|
t.Helper()
|
||||||
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
|
nonce := 0
|
||||||
elapsedTime := 420
|
elapsedTime := 420
|
||||||
@@ -183,13 +89,96 @@ func TestCookieCustomExpiration(t *testing.T) {
|
|||||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
||||||
req.URL.RawQuery = q.Encode()
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
requestReceiveLowerBound := time.Now()
|
resp, err := cli.Do(req)
|
||||||
resp, err = cli.Do(req)
|
|
||||||
requestReceiveUpperBound := time.Now()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("can't do challenge passing")
|
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)
|
||||||
|
requestReceiveUpperBound := time.Now()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusFound {
|
if resp.StatusCode != http.StatusFound {
|
||||||
resp.Write(os.Stderr)
|
resp.Write(os.Stderr)
|
||||||
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
|
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
|
||||||
@@ -226,59 +215,21 @@ func TestCookieSettings(t *testing.T) {
|
|||||||
Next: http.NewServeMux(),
|
Next: http.NewServeMux(),
|
||||||
Policy: pol,
|
Policy: pol,
|
||||||
|
|
||||||
CookieDomain: "local.cetacean.club",
|
CookieDomain: "127.0.0.1",
|
||||||
CookiePartitioned: true,
|
CookiePartitioned: true,
|
||||||
CookieName: t.Name(),
|
CookieName: t.Name(),
|
||||||
CookieExpiration: anubis.CookieDefaultExpirationTime,
|
CookieExpiration: anubis.CookieDefaultExpirationTime,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
requestReceiveLowerBound := time.Now()
|
||||||
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
cli := &http.Client{
|
cli := httpClient(t)
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
chall := makeChallenge(t, ts, cli)
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
resp := handleChallengeZeroDifficulty(t, ts, cli, chall)
|
||||||
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()
|
requestReceiveUpperBound := time.Now()
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("can't do challenge passing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusFound {
|
if resp.StatusCode != http.StatusFound {
|
||||||
resp.Write(os.Stderr)
|
resp.Write(os.Stderr)
|
||||||
@@ -298,8 +249,8 @@ func TestCookieSettings(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ckie.Domain != "local.cetacean.club" {
|
if ckie.Domain != "127.0.0.1" {
|
||||||
t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
|
t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
|
expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime)
|
||||||
@@ -323,7 +274,7 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
|
|||||||
|
|
||||||
for i := 1; i < 10; i++ {
|
for i := 1; i < 10; i++ {
|
||||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||||
anubisPolicy, err := LoadPoliciesOrDefault("", i)
|
anubisPolicy, err := LoadPoliciesOrDefault(t.Context(), "", i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -457,6 +408,10 @@ func TestBasePrefix(t *testing.T) {
|
|||||||
t.Fatalf("can't make request: %v", err)
|
t.Fatalf("can't make request: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, ckie := range resp.Cookies() {
|
||||||
|
req.AddCookie(ckie)
|
||||||
|
}
|
||||||
|
|
||||||
q := req.URL.Query()
|
q := req.URL.Query()
|
||||||
q.Set("response", calculated)
|
q.Set("response", calculated)
|
||||||
q.Set("nonce", fmt.Sprint(nonce))
|
q.Set("nonce", fmt.Sprint(nonce))
|
||||||
@@ -561,6 +516,25 @@ func TestCloudflareWorkersRule(t *testing.T) {
|
|||||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
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) {
|
t.Run("no-cf-worker-header", func(t *testing.T) {
|
||||||
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -40,7 +41,7 @@ type Options struct {
|
|||||||
ServeRobotsTXT bool
|
ServeRobotsTXT bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
|
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
|
||||||
var fin io.ReadCloser
|
var fin io.ReadCloser
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedC
|
|||||||
}
|
}
|
||||||
}(fin)
|
}(fin)
|
||||||
|
|
||||||
anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty)
|
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty)
|
||||||
|
|
||||||
return anubisPolicy, err
|
return anubisPolicy, err
|
||||||
}
|
}
|
||||||
|
|||||||
51
lib/http.go
51
lib/http.go
@@ -1,24 +1,41 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis"
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
"github.com/TecharoHQ/anubis/lib/policy"
|
"github.com/TecharoHQ/anubis/lib/policy"
|
||||||
"github.com/TecharoHQ/anubis/web"
|
"github.com/TecharoHQ/anubis/web"
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) ClearCookie(w http.ResponseWriter) {
|
func (s *Server) SetCookie(w http.ResponseWriter, name, value, path string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: s.cookieName,
|
Name: name,
|
||||||
Value: "",
|
Value: value,
|
||||||
Expires: time.Now().Add(-1 * time.Hour),
|
Expires: time.Now().Add(s.opts.CookieExpiration),
|
||||||
MaxAge: -1,
|
SameSite: http.SameSiteLaxMode,
|
||||||
SameSite: http.SameSiteLaxMode,
|
Domain: s.opts.CookieDomain,
|
||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +55,10 @@ func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return t.Transport.RoundTrip(req)
|
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) {
|
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) {
|
||||||
if returnHTTPStatusOnly {
|
if returnHTTPStatusOnly {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
@@ -47,6 +68,11 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
|
|||||||
|
|
||||||
lg := internal.GetRequestLogger(r)
|
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)
|
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||||
|
|
||||||
var ogTags map[string]string = nil
|
var ogTags map[string]string = nil
|
||||||
@@ -58,6 +84,13 @@ 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)
|
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags)
|
||||||
if err != nil {
|
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.
|
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.
|
||||||
@@ -65,10 +98,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := internal.NoStoreCache(templ.Handler(
|
handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
|
||||||
component,
|
component,
|
||||||
templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
|
templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
|
||||||
))
|
)))
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func TestClearCookie(t *testing.T) {
|
|||||||
srv := spawnAnubis(t, Options{})
|
srv := spawnAnubis(t, Options{})
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
srv.ClearCookie(rw)
|
srv.ClearCookie(rw, srv.cookieName, "/")
|
||||||
|
|
||||||
resp := rw.Result()
|
resp := rw.Result()
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ func TestClearCookieWithDomain(t *testing.T) {
|
|||||||
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
|
srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"})
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
srv.ClearCookie(rw)
|
srv.ClearCookie(rw, srv.cookieName, "/")
|
||||||
|
|
||||||
resp := rw.Result()
|
resp := rw.Result()
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/TecharoHQ/anubis/lib/policy/checker"
|
||||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
Rules Checker
|
Rules checker.Impl
|
||||||
Challenge *config.ChallengeRules
|
Challenge *config.ChallengeRules
|
||||||
Name string
|
Name string
|
||||||
Action config.Rule
|
Action config.Rule
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TecharoHQ/anubis/internal"
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
|
"github.com/TecharoHQ/anubis/lib/policy/checker"
|
||||||
"github.com/yl2chen/cidranger"
|
"github.com/yl2chen/cidranger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,43 +17,12 @@ var (
|
|||||||
ErrMisconfiguration = errors.New("[unexpected] policy: administrator misconfiguration")
|
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 {
|
type RemoteAddrChecker struct {
|
||||||
ranger cidranger.Ranger
|
ranger cidranger.Ranger
|
||||||
hash string
|
hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRemoteAddrChecker(cidrs []string) (Checker, error) {
|
func NewRemoteAddrChecker(cidrs []string) (checker.Impl, error) {
|
||||||
ranger := cidranger.NewPCTrieRanger()
|
ranger := cidranger.NewPCTrieRanger()
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
@@ -105,11 +75,11 @@ type HeaderMatchesChecker struct {
|
|||||||
hash string
|
hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserAgentChecker(rexStr string) (Checker, error) {
|
func NewUserAgentChecker(rexStr string) (checker.Impl, error) {
|
||||||
return NewHeaderMatchesChecker("User-Agent", rexStr)
|
return NewHeaderMatchesChecker("User-Agent", rexStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeaderMatchesChecker(header, rexStr string) (Checker, error) {
|
func NewHeaderMatchesChecker(header, rexStr string) (checker.Impl, error) {
|
||||||
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
|
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
|
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
|
||||||
@@ -134,7 +104,7 @@ type PathChecker struct {
|
|||||||
hash string
|
hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPathChecker(rexStr string) (Checker, error) {
|
func NewPathChecker(rexStr string) (checker.Impl, error) {
|
||||||
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
|
rex, err := regexp.Compile(strings.TrimSpace(rexStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
|
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
|
||||||
@@ -154,7 +124,7 @@ func (pc *PathChecker) Hash() string {
|
|||||||
return pc.hash
|
return pc.hash
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeaderExistsChecker(key string) Checker {
|
func NewHeaderExistsChecker(key string) checker.Impl {
|
||||||
return headerExistsChecker{strings.TrimSpace(key)}
|
return headerExistsChecker{strings.TrimSpace(key)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +144,8 @@ func (hec headerExistsChecker) Hash() string {
|
|||||||
return internal.SHA256sum(hec.header)
|
return internal.SHA256sum(hec.header)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeadersChecker(headermap map[string]string) (Checker, error) {
|
func NewHeadersChecker(headermap map[string]string) (checker.Impl, error) {
|
||||||
var result CheckerList
|
var result checker.List
|
||||||
var errs []error
|
var errs []error
|
||||||
|
|
||||||
for key, rexStr := range headermap {
|
for key, rexStr := range headermap {
|
||||||
|
|||||||
41
lib/policy/checker/checker.go
Normal file
41
lib/policy/checker/checker.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// 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())
|
||||||
|
}
|
||||||
44
lib/policy/config/asn.go
Normal file
44
lib/policy/config/asn.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,14 +51,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type BotConfig struct {
|
type BotConfig struct {
|
||||||
UserAgentRegex *string `json:"user_agent_regex"`
|
UserAgentRegex *string `json:"user_agent_regex,omitempty"`
|
||||||
PathRegex *string `json:"path_regex"`
|
PathRegex *string `json:"path_regex,omitempty"`
|
||||||
HeadersRegex map[string]string `json:"headers_regex"`
|
HeadersRegex map[string]string `json:"headers_regex,omitempty"`
|
||||||
Expression *ExpressionOrList `json:"expression"`
|
Expression *ExpressionOrList `json:"expression,omitempty"`
|
||||||
Challenge *ChallengeRules `json:"challenge,omitempty"`
|
Challenge *ChallengeRules `json:"challenge,omitempty"`
|
||||||
|
GeoIP *GeoIP `json:"geoip,omitempty"`
|
||||||
|
ASNs *ASNs `json:"asns,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Action Rule `json:"action"`
|
Action Rule `json:"action"`
|
||||||
RemoteAddr []string `json:"remote_addresses"`
|
RemoteAddr []string `json:"remote_addresses,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b BotConfig) Zero() bool {
|
func (b BotConfig) Zero() bool {
|
||||||
@@ -89,7 +91,9 @@ func (b BotConfig) Valid() error {
|
|||||||
allFieldsEmpty := b.UserAgentRegex == nil &&
|
allFieldsEmpty := b.UserAgentRegex == nil &&
|
||||||
b.PathRegex == nil &&
|
b.PathRegex == nil &&
|
||||||
len(b.RemoteAddr) == 0 &&
|
len(b.RemoteAddr) == 0 &&
|
||||||
len(b.HeadersRegex) == 0
|
len(b.HeadersRegex) == 0 &&
|
||||||
|
b.ASNs == nil &&
|
||||||
|
b.GeoIP == nil
|
||||||
|
|
||||||
if allFieldsEmpty && b.Expression == nil {
|
if allFieldsEmpty && b.Expression == nil {
|
||||||
errs = append(errs, ErrBotMustHaveUserAgentOrPath)
|
errs = append(errs, ErrBotMustHaveUserAgentOrPath)
|
||||||
|
|||||||
36
lib/policy/config/geoip.go
Normal file
36
lib/policy/config/geoip.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
33
lib/policy/config/geoip_test.go
Normal file
33
lib/policy/config/geoip_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/policy/config/testdata/good/challenge_cloudflare.yaml
vendored
Normal file
6
lib/policy/config/testdata/good/challenge_cloudflare.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
bots:
|
||||||
|
- name: challenge-cloudflare
|
||||||
|
action: CHALLENGE
|
||||||
|
asns:
|
||||||
|
match:
|
||||||
|
- 13335 # Cloudflare
|
||||||
6
lib/policy/config/testdata/good/geoip_us.yaml
vendored
Normal file
6
lib/policy/config/testdata/good/geoip_us.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
bots:
|
||||||
|
- name: compute-tarrif-us
|
||||||
|
action: CHALLENGE
|
||||||
|
geoip:
|
||||||
|
countries:
|
||||||
|
- US
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package policy
|
package policy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -8,6 +9,8 @@ import (
|
|||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/internal/thoth"
|
||||||
|
"github.com/TecharoHQ/anubis/lib/policy/checker"
|
||||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +19,8 @@ var (
|
|||||||
Name: "anubis_policy_results",
|
Name: "anubis_policy_results",
|
||||||
Help: "The results of each policy rule",
|
Help: "The results of each policy rule",
|
||||||
}, []string{"rule", "action"})
|
}, []string{"rule", "action"})
|
||||||
|
|
||||||
|
ErrNoThothClient = errors.New("config: you have specified Thoth related checks but have no active Thoth client")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ParsedConfig struct {
|
type ParsedConfig struct {
|
||||||
@@ -34,7 +39,7 @@ func NewParsedConfig(orig *config.Config) *ParsedConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
|
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
|
||||||
c, err := config.Load(fin, fname)
|
c, err := config.Load(fin, fname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -42,6 +47,8 @@ func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedCon
|
|||||||
|
|
||||||
var validationErrs []error
|
var validationErrs []error
|
||||||
|
|
||||||
|
tc, hasThothClient := thoth.FromContext(ctx)
|
||||||
|
|
||||||
result := NewParsedConfig(c)
|
result := NewParsedConfig(c)
|
||||||
result.DefaultDifficulty = defaultDifficulty
|
result.DefaultDifficulty = defaultDifficulty
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedCon
|
|||||||
Action: b.Action,
|
Action: b.Action,
|
||||||
}
|
}
|
||||||
|
|
||||||
cl := CheckerList{}
|
cl := checker.List{}
|
||||||
|
|
||||||
if len(b.RemoteAddr) > 0 {
|
if len(b.RemoteAddr) > 0 {
|
||||||
c, err := NewRemoteAddrChecker(b.RemoteAddr)
|
c, err := NewRemoteAddrChecker(b.RemoteAddr)
|
||||||
@@ -103,6 +110,24 @@ func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
if b.Challenge == nil {
|
||||||
parsedBot.Challenge = &config.ChallengeRules{
|
parsedBot.Challenge = &config.ChallengeRules{
|
||||||
Difficulty: defaultDifficulty,
|
Difficulty: defaultDifficulty,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/TecharoHQ/anubis"
|
"github.com/TecharoHQ/anubis"
|
||||||
"github.com/TecharoHQ/anubis/data"
|
"github.com/TecharoHQ/anubis/data"
|
||||||
|
"github.com/TecharoHQ/anubis/internal/thoth"
|
||||||
|
"github.com/TecharoHQ/anubis/internal/thoth/thothmock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultPolicyMustParse(t *testing.T) {
|
func TestDefaultPolicyMustParse(t *testing.T) {
|
||||||
@@ -16,7 +18,11 @@ func TestDefaultPolicyMustParse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fin.Close()
|
defer fin.Close()
|
||||||
|
|
||||||
if _, err := ParseConfig(fin, "botPolicies.json", anubis.DefaultDifficulty); err != nil {
|
thothCli := &thoth.Client{}
|
||||||
|
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
|
||||||
|
ctx := thoth.With(t.Context(), thothCli)
|
||||||
|
|
||||||
|
if _, err := ParseConfig(ctx, fin, "botPolicies.json", anubis.DefaultDifficulty); err != nil {
|
||||||
t.Fatalf("can't parse config: %v", err)
|
t.Fatalf("can't parse config: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +42,11 @@ func TestGoodConfigs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fin.Close()
|
defer fin.Close()
|
||||||
|
|
||||||
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
|
thothCli := &thoth.Client{}
|
||||||
|
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
|
||||||
|
ctx := thoth.With(t.Context(), thothCli)
|
||||||
|
|
||||||
|
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -58,7 +68,11 @@ func TestBadConfigs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fin.Close()
|
defer fin.Close()
|
||||||
|
|
||||||
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
|
thothCli := &thoth.Client{}
|
||||||
|
thothCli.WithIPToASNService(thothmock.MockIpToASNService())
|
||||||
|
ctx := thoth.With(t.Context(), thothCli)
|
||||||
|
|
||||||
|
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
} else {
|
} else {
|
||||||
t.Log(err)
|
t.Log(err)
|
||||||
|
|||||||
6
lib/testdata/cloudflare-workers-cel.yaml
vendored
6
lib/testdata/cloudflare-workers-cel.yaml
vendored
@@ -1,4 +1,8 @@
|
|||||||
bots:
|
bots:
|
||||||
- name: cloudflare-workers
|
- name: cloudflare-workers
|
||||||
expression: '"Cf-Worker" in headers'
|
expression: '"Cf-Worker" in headers'
|
||||||
action: DENY
|
action: DENY
|
||||||
|
|
||||||
|
status_codes:
|
||||||
|
CHALLENGE: 401
|
||||||
|
DENY: 403
|
||||||
6
lib/testdata/cloudflare-workers-header.yaml
vendored
6
lib/testdata/cloudflare-workers-header.yaml
vendored
@@ -2,4 +2,8 @@ bots:
|
|||||||
- name: cloudflare-workers
|
- name: cloudflare-workers
|
||||||
headers_regex:
|
headers_regex:
|
||||||
CF-Worker: .*
|
CF-Worker: .*
|
||||||
action: DENY
|
action: DENY
|
||||||
|
|
||||||
|
status_codes:
|
||||||
|
CHALLENGE: 401
|
||||||
|
DENY: 403
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker",
|
"test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker",
|
||||||
"assets": "go generate ./... && ./web/build.sh && ./xess/build.sh",
|
"assets": "go generate ./... && ./web/build.sh && ./xess/build.sh",
|
||||||
"build": "npm run assets && go build -o ./var/anubis ./cmd/anubis",
|
"build": "npm run assets && go build -o ./var/anubis ./cmd/anubis",
|
||||||
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address",
|
"dev": "npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000",
|
||||||
"container": "npm run assets && go run ./cmd/containerbuild",
|
"container": "npm run assets && go run ./cmd/containerbuild",
|
||||||
"package": "yeet",
|
"package": "yeet",
|
||||||
"lint": "make lint"
|
"lint": "make lint"
|
||||||
@@ -27,4 +27,4 @@
|
|||||||
"postcss-import-url": "^7.2.0",
|
"postcss-import-url": "^7.2.0",
|
||||||
"postcss-url": "^10.1.3"
|
"postcss-url": "^10.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ CacheDirectory=anubis/%i
|
|||||||
CacheDirectoryMode=0755
|
CacheDirectoryMode=0755
|
||||||
StateDirectory=anubis/%i
|
StateDirectory=anubis/%i
|
||||||
StateDirectoryMode=0755
|
StateDirectoryMode=0755
|
||||||
|
RuntimeDirectory=anubis
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
ReadWritePaths=/run
|
ReadWritePaths=/run
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ User-agent: PanguBot
|
|||||||
User-agent: Perplexity-User
|
User-agent: Perplexity-User
|
||||||
User-agent: PerplexityBot
|
User-agent: PerplexityBot
|
||||||
User-agent: PetalBot
|
User-agent: PetalBot
|
||||||
|
User-agent: QualifiedBot
|
||||||
User-agent: Scrapy
|
User-agent: Scrapy
|
||||||
User-agent: SemrushBot-OCOB
|
User-agent: SemrushBot-OCOB
|
||||||
User-agent: SemrushBot-SWA
|
User-agent: SemrushBot-SWA
|
||||||
|
|||||||
Reference in New Issue
Block a user