Compare commits

..

17 Commits

Author SHA1 Message Date
Jason Cameron
4ec2abec5f fix(tests): stabilize CVE-2025-24369 regression test by using invalid proof 2025-11-16 18:15:52 -05:00
Jason Cameron
24e6f152d3 fix(tests): make CVE-2025-24369 regression deterministic 2025-11-16 18:13:47 -05:00
Jason Cameron
9dd4de6f1f perf: apply fieldalignement (#1284) 2025-11-16 20:43:07 +00:00
kouhaidev
da1890380e docs: use nginx http2 directive instead of deprecated http2 listen parameter (#1251)
Acked-by: Jason Cameron <git@jasoncameron.dev>
2025-11-16 06:59:16 +00:00
Henri Vasserman
6c8629e3ac test: Valkey test improvements for testcontainers (#1280)
* test: testcontainers improvements

Use the endpoint feature to get the connection URL for the container.

There are cases where localhost is not the correct one, for example when DOCKER_HOST is set to another machine.

Also, don't specify the external port for the mapping so a random unused port is used, in cases when there is already Valkey/Redis running as a container and port mapped externally on 6379.

* also remove this hack, doesn't seem necessary.
2025-11-15 14:32:37 -05:00
DerRockWolf
f6bf98fa28 feat(internal/headers): extend debug logging of X-Forwarded-For middlewares (#1269) 2025-11-15 14:31:43 -05:00
Jason Cameron
97ba84e26d Fix challenge validation panic when follow-up hits ALLOW (#1278)
* fix(localization): correct formatting of Swedish loading message

* fix(main): correct formatting and improve readability in main.go

* fix(challenge): add difficulty and policy rule hash to challenge metadata

* docs(challenge): fix panic when validating challenges in privacy-mode browsers
2025-11-14 19:51:48 -05:00
Xe Iaso
68fcc0c44f feat(lib): expose WEIGH matches as prometheus metrics (#1277)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-11-14 17:12:59 -05:00
Esteban Gimbernat
6a7f80e6f5 (feat) Add cluster support to redis/vaultkey store (#1276)
* (feat) Add cluster support to redis/vaultkey store

* (chore) Update CHANGELOG.md

* (fix) Disable maintenance notification on the Valkey store

* (fix) Valkey text fix and allow maintnotifications in spelling.
2025-11-14 08:22:22 -05:00
Henri Vasserman
a5bb6d2751 test: ipv4 in v6 address checking (#1271)
* test: ipv4 in v6 address checking

* fix(lib/policy): unmap 4in6 addresses in RemoteAddrChecker

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: update CHANGELOG

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: perfect CHANGELOG

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Co-authored-by: Xe Iaso <me@xeiaso.net>
2025-11-14 03:39:50 +00:00
kouhaidev
1e298f5d0e fix(run): mark openrc service script as executable (#1272)
Signed-off-by: Kouhai <66407198+kouhaidev@users.noreply.github.com>
2025-11-13 22:14:21 -05:00
Xe Iaso
a4770956a8 fix(docs): use node:lts (#1274)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-11-14 03:14:00 +00:00
Josh Deprez
316905bf1d Add Renovate to Docker clients (#1267)
Renovate-bot looks at the container APIs directly to learn about new image versions and digests. The [default User-Agent](https://docs.renovatebot.com/self-hosted-configuration/#useragent) is `Renovate/${renovateVersion} (https://github.com/renovatebot/renovate)`
2025-11-12 03:22:00 +00:00
dependabot[bot]
1a12171d74 build(deps): bump the github-actions group with 3 updates (#1262)
Co-authored-by: Jason Cameron <git@jasoncameron.dev>
2025-11-09 18:08:06 -08:00
Denys Nykula
4f50d3245e feat(localization): Add Ukrainian language translation (#1044) 2025-11-08 18:46:20 +00:00
Xe Iaso
49c9333359 fix(data): add services folder to embedded filesystem (#1259)
* fix(data): add services folder to embedded filesystem

Also includes a regression test to ensure this does not happen again.

Assisted-By: GLM 4.6 via Claude Code

* docs: update CHANGELOG

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-11-08 18:08:48 +00:00
Xe Iaso
c7e4cd1032 fix(data/docker-client): allow some more OCI clients through (#1258)
* fix(data/docker-client): allow some more OCI clients through

Signed-off-by: Xe Iaso <me@xeiaso.net>

* Update metadata

check-spelling run (pull_request) for Xe/more-docker-client-programs

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev>

* fix(data/docker-client): add containerd

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
2025-11-08 17:50:56 +00:00
54 changed files with 504 additions and 230 deletions

View File

@@ -8,3 +8,4 @@ msgbox
xeact xeact
ABee ABee
tencent tencent
maintnotifications

View File

@@ -22,11 +22,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential sudo apt-get install -y build-essential
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: latest
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: stable

View File

@@ -26,11 +26,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential sudo apt-get install -y build-essential
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: latest
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: stable
@@ -38,7 +38,7 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with: with:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}

View File

@@ -36,11 +36,11 @@ jobs:
run: | run: |
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: latest
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: stable
@@ -55,7 +55,7 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with: with:
images: ${{ env.IMAGE }} images: ${{ env.IMAGE }}

View File

@@ -33,7 +33,7 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with: with:
images: ghcr.io/techarohq/anubis/docs images: ghcr.io/techarohq/anubis/docs
tags: | tags: |

View File

@@ -22,7 +22,7 @@ jobs:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with: with:
images: ghcr.io/techarohq/anubis/docs images: ghcr.io/techarohq/anubis/docs
tags: | tags: |

View File

@@ -24,11 +24,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential sudo apt-get install -y build-essential
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: latest
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: stable

View File

@@ -25,11 +25,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential sudo apt-get install -y build-essential
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: latest
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: stable

View File

@@ -26,11 +26,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential sudo apt-get install -y build-essential
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version: latest node-version: latest
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with: with:
go-version: stable go-version: stable

View File

@@ -83,7 +83,7 @@ var (
versionFlag = flag.Bool("version", false, "print Anubis version") versionFlag = flag.Bool("version", false, "print Anubis version")
publicUrl = flag.String("public-url", "", "the externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for forwardAuth).") publicUrl = flag.String("public-url", "", "the externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for forwardAuth).")
xffStripPrivate = flag.Bool("xff-strip-private", true, "if set, strip private addresses from X-Forwarded-For") xffStripPrivate = flag.Bool("xff-strip-private", true, "if set, strip private addresses from X-Forwarded-For")
customRealIPHeader = flag.String("custom-real-ip-header", "", "if set, read remote IP from header of this name (in case your environment doesn't set X-Real-IP header)") customRealIPHeader = flag.String("custom-real-ip-header", "", "if set, read remote IP from header of this name (in case your environment doesn't set X-Real-IP header)")
thothInsecure = flag.Bool("thoth-insecure", false, "if set, connect to Thoth over plain HTTP/2, don't enable this unless support told you to") thothInsecure = flag.Bool("thoth-insecure", false, "if set, connect to Thoth over plain HTTP/2, don't enable this unless support told you to")
thothURL = flag.String("thoth-url", "", "if set, URL for Thoth, the IP reputation database for Anubis") thothURL = flag.String("thoth-url", "", "if set, URL for Thoth, the IP reputation database for Anubis")
@@ -145,19 +145,19 @@ func parseBindNetFromAddr(address string) (string, string) {
return "", address return "", address
} }
func parseSameSite(s string) (http.SameSite) { func parseSameSite(s string) http.SameSite {
switch strings.ToLower(s) { switch strings.ToLower(s) {
case "none": case "none":
return http.SameSiteNoneMode return http.SameSiteNoneMode
case "lax": case "lax":
return http.SameSiteLaxMode return http.SameSiteLaxMode
case "strict": case "strict":
return http.SameSiteStrictMode return http.SameSiteStrictMode
case "default": case "default":
return http.SameSiteDefaultMode return http.SameSiteDefaultMode
default: default:
log.Fatalf("invalid cookie same-site mode: %s, valid values are None, Lax, Strict, and Default", s) log.Fatalf("invalid cookie same-site mode: %s, valid values are None, Lax, Strict, and Default", s)
} }
return http.SameSiteDefaultMode return http.SameSiteDefaultMode
} }

View File

@@ -22,9 +22,9 @@ type TestCase struct {
type TestOptions struct { type TestOptions struct {
format string format string
action string action string
crawlDelayWeight int
policyName string policyName string
deniedAction string deniedAction string
crawlDelayWeight int
} }
func TestDataFileConversion(t *testing.T) { func TestDataFileConversion(t *testing.T) {

View File

@@ -51,3 +51,10 @@
all: all:
- path.startsWith("/v2/") - path.startsWith("/v2/")
- userAgent.contains("containerd/") - userAgent.contains("containerd/")
- name: allow-renovate
action: ALLOW
expression:
all:
- path.startsWith("/v2/")
- userAgent.contains("Renovate/")

View File

@@ -3,6 +3,6 @@ package data
import "embed" import "embed"
var ( var (
//go:embed botPolicies.yaml all:apps all:bots all:clients all:common all:crawlers all:meta //go:embed botPolicies.yaml all:apps all:bots all:clients all:common all:crawlers all:meta all:services
BotPolicies embed.FS BotPolicies embed.FS
) )

38
data/embed_test.go Normal file
View File

@@ -0,0 +1,38 @@
package data
import (
"path/filepath"
"strings"
"testing"
)
// TestBotPoliciesEmbed ensures all YAML files in the directory tree
// are accessible in the embedded BotPolicies filesystem.
func TestBotPoliciesEmbed(t *testing.T) {
yamlFiles, err := filepath.Glob("./**/*.yaml")
if err != nil {
t.Fatalf("Failed to glob YAML files: %v", err)
}
if len(yamlFiles) == 0 {
t.Fatal("No YAML files found in directory tree")
}
t.Logf("Found %d YAML files to verify", len(yamlFiles))
for _, filePath := range yamlFiles {
embeddedPath := strings.TrimPrefix(filePath, "./")
t.Run(embeddedPath, func(t *testing.T) {
content, err := BotPolicies.ReadFile(embeddedPath)
if err != nil {
t.Errorf("Failed to read %s from embedded filesystem: %v", embeddedPath, err)
return
}
if len(content) == 0 {
t.Errorf("File %s exists in embedded filesystem but is empty", embeddedPath)
}
})
}
}

View File

@@ -13,13 +13,13 @@ func Zilch[T any]() T {
// Impl is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time. // Impl is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time.
type Impl[K comparable, V any] struct { type Impl[K comparable, V any] struct {
data map[K]decayMapEntry[V] data map[K]decayMapEntry[V]
lock sync.RWMutex
// deleteCh receives decay-deletion requests from readers. // deleteCh receives decay-deletion requests from readers.
deleteCh chan deleteReq[K] deleteCh chan deleteReq[K]
// stopCh stops the background cleanup worker. // stopCh stops the background cleanup worker.
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
lock sync.RWMutex
} }
type decayMapEntry[V any] struct { type decayMapEntry[V any] struct {

View File

@@ -1,4 +1,4 @@
FROM docker.io/library/node AS build FROM docker.io/library/node:lts AS build
WORKDIR /app WORKDIR /app
COPY . . COPY . .

View File

@@ -13,7 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: --> <!-- This changes the project to: -->
- Fix panic when validating challenges after privacy-mode browsers strip headers and the follow-up request matches an `ALLOW` threshold.
- Expose WEIGHT rule matches as Prometheus metrics.
- Allow more OCI registry clients [based on feedback](https://github.com/TecharoHQ/anubis/pull/1253#issuecomment-3506744184). - Allow more OCI registry clients [based on feedback](https://github.com/TecharoHQ/anubis/pull/1253#issuecomment-3506744184).
- Expose services directory in the embedded `(data)` filesystem.
- Add Ukrainian locale ([#1044](https://github.com/TecharoHQ/anubis/pull/1044)).
- Allow Renovate as an OCI registry client.
- Properly handle 4in6 addresses so that IP matching works with those addresses.
- Add support to simple Valkey/Redis cluster mode
- Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures.
## v1.23.1: Lyse Hext - Echo 1 ## v1.23.1: Lyse Hext - Echo 1

View File

@@ -55,8 +55,9 @@ server {
# proxy all traffic to the target via Anubis. # proxy all traffic to the target via Anubis.
server { server {
# Listen on TCP port 443 with TLS (https) and HTTP/2 # Listen on TCP port 443 with TLS (https) and HTTP/2
listen 443 ssl http2; listen 443 ssl;
listen [::]:443 ssl http2; listen [::]:443 ssl;
http2 on;
location / { location / {
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -113,8 +114,9 @@ Then in a server block:
server { server {
# Listen on 443 with SSL # Listen on 443 with SSL
listen 443 ssl http2; listen 443 ssl;
listen [::]:443 ssl http2; listen [::]:443 ssl;
http2 on;
# Slipstream via Anubis # Slipstream via Anubis
include "conf-anubis.inc"; include "conf-anubis.inc";

View File

@@ -87,7 +87,7 @@ func XForwardedForToXRealIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-Ip") == "" && xffHeader != "" { if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-Ip") == "" && xffHeader != "" {
ip := xff.Parse(xffHeader) ip := xff.Parse(xffHeader)
slog.Debug("setting x-real-ip", "val", ip) slog.Debug("setting X-Real-Ip from X-Forwarded-For", "to", ip, "x-forwarded-for", xffHeader)
r.Header.Set("X-Real-Ip", ip) r.Header.Set("X-Real-Ip", ip)
} }
@@ -129,6 +129,8 @@ func XForwardedForUpdate(stripPrivate bool, next http.Handler) http.Handler {
} else { } else {
r.Header.Set("X-Forwarded-For", xffHeaderString) r.Header.Set("X-Forwarded-For", xffHeaderString)
} }
slog.Debug("updating X-Forwarded-For", "original", origXFFHeader, "new", xffHeaderString)
}) })
} }

View File

@@ -22,9 +22,10 @@ const (
) )
type OGTagCache struct { type OGTagCache struct {
cache store.JSON[map[string]string] targetURL *url.URL
targetURL *url.URL client *http.Client
client *http.Client ogOverride map[string]string
cache store.JSON[map[string]string]
// Pre-built strings for optimization // Pre-built strings for optimization
unixPrefix string // "http://unix" unixPrefix string // "http://unix"
@@ -33,7 +34,6 @@ type OGTagCache struct {
ogTimeToLive time.Duration ogTimeToLive time.Duration
ogCacheConsiderHost bool ogCacheConsiderHost bool
ogPassthrough bool ogPassthrough bool
ogOverride map[string]string
} }
func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface) *OGTagCache { func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface) *OGTagCache {

View File

@@ -68,14 +68,14 @@ var (
type Server struct { type Server struct {
next http.Handler next http.Handler
store store.Interface
mux *http.ServeMux mux *http.ServeMux
policy *policy.ParsedConfig policy *policy.ParsedConfig
OGTags *ogtags.OGTagCache OGTags *ogtags.OGTagCache
logger *slog.Logger
opts Options
ed25519Priv ed25519.PrivateKey ed25519Priv ed25519.PrivateKey
hs512Secret []byte hs512Secret []byte
opts Options
store store.Interface
logger *slog.Logger
} }
func (s *Server) getTokenKeyfunc() jwt.Keyfunc { func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
@@ -117,10 +117,12 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
} }
chall := challenge.Challenge{ chall := challenge.Challenge{
ID: id.String(), ID: id.String(),
Method: rule.Challenge.Algorithm, Method: rule.Challenge.Algorithm,
RandomData: fmt.Sprintf("%x", randomData), RandomData: fmt.Sprintf("%x", randomData),
IssuedAt: time.Now(), IssuedAt: time.Now(),
Difficulty: rule.Challenge.Difficulty,
PolicyRuleHash: rule.Hash(),
Metadata: map[string]string{ Metadata: map[string]string{
"User-Agent": r.Header.Get("User-Agent"), "User-Agent": r.Header.Get("User-Agent"),
"X-Real-Ip": r.Header.Get("X-Real-Ip"), "X-Real-Ip": r.Header.Get("X-Real-Ip"),
@@ -137,6 +139,44 @@ func (s *Server) issueChallenge(ctx context.Context, r *http.Request, lg *slog.L
return &chall, err return &chall, err
} }
func (s *Server) hydrateChallengeRule(rule *policy.Bot, chall *challenge.Challenge, lg *slog.Logger) *policy.Bot {
if chall == nil {
return rule
}
if rule == nil {
rule = &policy.Bot{
Rules: &checker.List{},
}
}
if chall.Difficulty == 0 {
// fall back to whatever the policy currently says or the global default
if rule.Challenge != nil && rule.Challenge.Difficulty != 0 {
chall.Difficulty = rule.Challenge.Difficulty
} else {
chall.Difficulty = s.policy.DefaultDifficulty
}
}
if rule.Challenge == nil {
lg.Warn("rule missing challenge configuration; using stored challenge metadata", "rule", rule.Name)
rule.Challenge = &config.ChallengeRules{}
}
if rule.Challenge.Difficulty == 0 {
rule.Challenge.Difficulty = chall.Difficulty
}
if rule.Challenge.ReportAs == 0 {
rule.Challenge.ReportAs = chall.Difficulty
}
if rule.Challenge.Algorithm == "" {
rule.Challenge.Algorithm = chall.Method
}
return rule
}
func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) { func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) {
s.maybeReverseProxy(w, r, true) s.maybeReverseProxy(w, r, true)
} }
@@ -461,6 +501,8 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
return return
} }
rule = s.hydrateChallengeRule(rule, chall, lg)
impl, ok := challenge.Get(chall.Method) impl, ok := challenge.Get(chall.Method)
if !ok { if !ok {
lg.Error("check failed", "err", err) lg.Error("check failed", "err", err)
@@ -576,6 +618,7 @@ func (s *Server) check(r *http.Request, lg *slog.Logger) (policy.CheckResult, *p
return cr("bot/"+b.Name, b.Action, weight), &b, nil return cr("bot/"+b.Name, b.Action, weight), &b, nil
case config.RuleWeigh: case config.RuleWeigh:
lg.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust) lg.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
policy.Applications.WithLabelValues("bot/"+b.Name, "WEIGH").Add(1)
weight += b.Weight.Adjust weight += b.Weight.Adjust
} }
} }

View File

@@ -2,6 +2,7 @@ package lib
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -18,8 +19,10 @@ 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/lib/challenge"
"github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config" "github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/thoth/thothmock" "github.com/TecharoHQ/anubis/lib/thoth/thothmock"
) )
@@ -149,10 +152,34 @@ func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.
return resp return resp
} }
func handleChallengeInvalidProof(t *testing.T, ts *httptest.Server, cli *http.Client, chall challengeResp) *http.Response {
t.Helper()
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", strings.Repeat("f", 64)) // "hash" that never starts with the nonce
q.Set("nonce", "0")
q.Set("redir", "/")
q.Set("elapsedTime", "0")
q.Set("id", chall.ID)
req.URL.RawQuery = q.Encode()
resp, err := cli.Do(req)
if err != nil {
t.Fatalf("can't do request: %v", err)
}
return resp
}
type loggingCookieJar struct { type loggingCookieJar struct {
t *testing.T t *testing.T
lock sync.Mutex
cookies map[string][]*http.Cookie cookies map[string][]*http.Cookie
lock sync.Mutex
} }
func (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie { func (lcj *loggingCookieJar) Cookies(u *url.URL) []*http.Cookie {
@@ -244,7 +271,7 @@ func TestCVE2025_24369(t *testing.T) {
cli := httpClient(t) cli := httpClient(t)
chall := makeChallenge(t, ts, cli) chall := makeChallenge(t, ts, cli)
resp := handleChallengeZeroDifficulty(t, ts, cli, chall) resp := handleChallengeInvalidProof(t, ts, cli, chall)
if resp.StatusCode == http.StatusFound { if resp.StatusCode == http.StatusFound {
t.Log("Regression on CVE-2025-24369") t.Log("Regression on CVE-2025-24369")
@@ -744,9 +771,9 @@ func TestStripBasePrefixFromRequest(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
basePrefix string basePrefix string
stripBasePrefix bool
requestPath string requestPath string
expectedPath string expectedPath string
stripBasePrefix bool
}{ }{
{ {
name: "strip disabled - no change", name: "strip disabled - no change",
@@ -1027,6 +1054,59 @@ func TestPassChallengeXSS(t *testing.T) {
}) })
} }
func TestPassChallengeNilRuleChallengeFallback(t *testing.T) {
pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
})
allowThreshold, err := policy.ParsedThresholdFromConfig(config.Threshold{
Name: "allow-all",
Expression: &config.ExpressionOrList{
Expression: "true",
},
Action: config.RuleAllow,
})
if err != nil {
t.Fatalf("can't compile test threshold: %v", err)
}
srv.policy.Thresholds = []*policy.Threshold{allowThreshold}
srv.policy.Bots = nil
chall := challenge.Challenge{
ID: "test-challenge",
Method: "metarefresh",
RandomData: "apple cider",
IssuedAt: time.Now().Add(-5 * time.Second),
Difficulty: 1,
}
j := store.JSON[challenge.Challenge]{Underlying: srv.store}
if err := j.Set(context.Background(), "challenge:"+chall.ID, chall, time.Minute); err != nil {
t.Fatalf("can't insert challenge into store: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "https://example.com"+anubis.APIPrefix+"pass-challenge", nil)
q := req.URL.Query()
q.Set("redir", "/")
q.Set("id", chall.ID)
q.Set("challenge", chall.RandomData)
req.URL.RawQuery = q.Encode()
req.Header.Set("X-Real-Ip", "203.0.113.4")
req.Header.Set("User-Agent", "NilChallengeTester/1.0")
req.AddCookie(&http.Cookie{Name: anubis.TestCookieName, Value: chall.ID})
rr := httptest.NewRecorder()
srv.PassChallenge(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected redirect when validating challenge, got %d", rr.Code)
}
}
func TestXForwardedForNoDoubleComma(t *testing.T) { func TestXForwardedForNoDoubleComma(t *testing.T) {
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For")) w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))

View File

@@ -4,10 +4,12 @@ import "time"
// Challenge is the metadata about a single challenge issuance. // Challenge is the metadata about a single challenge issuance.
type Challenge struct { type Challenge struct {
ID string `json:"id"` // UUID identifying the challenge IssuedAt time.Time `json:"issuedAt"`
Method string `json:"method"` // Challenge method Metadata map[string]string `json:"metadata"`
RandomData string `json:"randomData"` // The random data the client processes ID string `json:"id"`
IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued Method string `json:"method"`
Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent RandomData string `json:"randomData"`
Spent bool `json:"spent"` // Has the challenge already been solved? PolicyRuleHash string `json:"policyRuleHash,omitempty"`
Difficulty int `json:"difficulty,omitempty"`
Spent bool `json:"spent"`
} }

View File

@@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/challenge"
"github.com/google/uuid" "github.com/google/uuid"
@@ -19,5 +20,6 @@ func New(t *testing.T) *challenge.Challenge {
ID: id.String(), ID: id.String(),
RandomData: randomData, RandomData: randomData,
IssuedAt: time.Now(), IssuedAt: time.Now(),
Difficulty: anubis.DefaultDifficulty,
} }
} }

View File

@@ -29,24 +29,24 @@ import (
type Options struct { type Options struct {
Next http.Handler Next http.Handler
Policy *policy.ParsedConfig Policy *policy.ParsedConfig
Target string Logger *slog.Logger
CookieDynamicDomain bool OpenGraph config.OpenGraph
PublicUrl string
CookieDomain string CookieDomain string
CookieExpiration time.Duration JWTRestrictionHeader string
CookiePartitioned bool
BasePrefix string BasePrefix string
WebmasterEmail string WebmasterEmail string
Target string
RedirectDomains []string RedirectDomains []string
ED25519PrivateKey ed25519.PrivateKey ED25519PrivateKey ed25519.PrivateKey
HS512Secret []byte HS512Secret []byte
StripBasePrefix bool CookieExpiration time.Duration
OpenGraph config.OpenGraph CookieSameSite http.SameSite
ServeRobotsTXT bool ServeRobotsTXT bool
CookieSecure bool CookieSecure bool
CookieSameSite http.SameSite StripBasePrefix bool
Logger *slog.Logger CookiePartitioned bool
PublicUrl string CookieDynamicDomain bool
JWTRestrictionHeader string
DifficultyInJWT bool DifficultyInJWT bool
} }

View File

@@ -13,9 +13,9 @@ import (
func TestSetCookie(t *testing.T) { func TestSetCookie(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string name string
options Options
host string host string
cookieName string cookieName string
options Options
}{ }{
{ {
name: "basic", name: "basic",

View File

@@ -18,6 +18,7 @@
"pt-BR", "pt-BR",
"ru", "ru",
"tr", "tr",
"uk",
"vi", "vi",
"zh-CN", "zh-CN",
"zh-TW", "zh-TW",

View File

@@ -0,0 +1,66 @@
{
"loading": "Завантаження...",
"why_am_i_seeing": "Чому я це бачу?",
"protected_by": "Захищено засобами",
"protected_from": "за авторством",
"made_with": "Зроблено з ❤️ у 🇨🇦",
"mascot_design": "Дизайн персонажа від",
"ai_companies_explanation": "Ви це бачите, оскільки адміністрація сайту налаштувала Anubis, щоб захистити сервер від тиску ШІ-компаній, які агресивно сканують вебсайти. Їхня діяльність спричиняє перебої в роботі вебсайтів, що робить матеріали недоступними для всіх.",
"anubis_compromise": "Anubis — це компроміс. Anubis втілює схему доказу виконаної роботи подібно до Hashcash — засобу боротьби зі спамом. По ідеї, додаткове навантаження не обтяжує справжню людину, котра робить небагато запитів, а от масове сканування таким чином стає суттєво дорожчим.",
"hack_purpose": "Це тимчасове рішення, котре дозволяє приділити більше часу розпізнанню й виокремленню автоматизованих браузерів (наприклад, за тим, як вони промальовують шрифти), щоб сторінку перевірки доказу виконаної роботи не доводилося показувати ймовірно справжнім користувачам.",
"simplified_explanation": "Це засіб боротьби з ботами й зловмисними запитами, подібний до капчі. Проте замість того, щоб просити вас щось зробити, він пропонує вашому браузеру розв'язати обчислювальне завдання. Ця концепція називається <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">доказом виконаної роботи</a>. Завдання обчислюється кілька секунд, після чого вам надається доступ до сайту. Дякуємо за розуміння й терплячість.",
"jshelter_note": "Зауважте, Anubis потребує сучасного JavaScript-функціоналу, котрий може бути недоступним при використанні розширень на зразок JShelter. Будь ласка, вимкніть JShelter чи інші подібні розширення для цього домену.",
"version_info": "Цей вебсайт застосовує Anubis версії",
"try_again": "Повторіть спробу",
"go_home": "Перейдіть на головну сторінку",
"contact_webmaster": "або, якщо ви певні в помилковості блокування, сконтактуйте з адміністрацією за адресою",
"connection_security": "Зачекайте хвилинку, поки ми перевіримо безпеку вашого з'єднання.",
"javascript_required": "На жаль, вам потрібно ввімкнути JavaScript, щоб пройти цю перевірку. Це необхідно, оскільки ШІ-компанії нехтують суспільним договором, завдяки якому можливо утримувати вебсайти. Робота над рішенням без використання JS триває.",
"benchmark_requires_js": "Щоб запустити тестування продуктивності, ввімкніть JavaScript.",
"difficulty": "Складність:",
"algorithm": "Алгоритм:",
"compare": "Порівняти:",
"time": "Час",
"iters": "Ітерації",
"time_a": "Час A",
"iters_a": "Ітерації A",
"time_b": "Час B",
"iters_b": "Ітерації B",
"static_check_endpoint": "Це просто сторінка перевірки для вашого зворотного проксі.",
"authorization_required": "Необхідно авторизуватися",
"cookies_disabled": "У вашому браузері вимкнено кукі. Anubis використовує кукі, щоб упевнитись, що ви дійсно людина. Це законний інтерес. Будь ласка, ввімкніть кукі для цього домену",
"access_denied": "Доступ заборонено: код помилки",
"dronebl_entry": "DroneBL містить пункт",
"see_dronebl_lookup": "див.",
"internal_server_error": "Внутрішня помилка сервера: адміністрація хибно налаштувала Anubis. Будь ласка, сконтактуйте з адміністрацією й попросіть глянути логи довкола",
"invalid_redirect": "Хибне переспрямування",
"redirect_not_parseable": "Не вдається розпізнати URL-адресу переспрямування",
"redirect_domain_not_allowed": "Заборонений домен переспрямування",
"missing_required_forwarded_headers": "Бракує обов'язкових заголовків X-Forwarded-*",
"failed_to_sign_jwt": "не вдається підписати JWT",
"invalid_invocation": "Хибний виклик MakeChallenge",
"client_error_browser": "Помилка клієнта: переконайтесь, що використовуєте браузер актуальної версії, й повторіть спробу.",
"oh_noes": "Йой!",
"benchmarking_anubis": "Тестування продуктивності Anubis!",
"you_are_not_a_bot": "Ви не бот!",
"making_sure_not_bot": "Перевірка, чи ви не бот!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Ваш браузер не надає web.crypto. Ви певні, що дивитесь це через захищений контекст?",
"js_web_workers_error": "Ваш браузер не підтримує Web Workers (Anubis використовує їх, щоб ваш браузер не зависав на час перевірки). Можливо, у вас встановлено розширення на зразок JShelter?",
"js_cookies_error": "Ваш браузер не зберігає кукі. Anubis записує підписаний токен до кукі, щоб занотувати, що клієнт пройшов перевірку. Будь ласка, ввімкніть збереження кукі для цього домену. Назви кукі, які записує Anubis, можуть змінюватися без попередження. Назви й значення кукі не є частиною публічного API.",
"js_context_not_secure": "Ваш контекст незахищений!",
"js_context_not_secure_msg": "Спробуйте з'єднатися через HTTPS або попросіть адміністрацію налаштувати HTTPS. Докладніше — в <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Обчислення...",
"js_missing_feature": "Бракує функціоналу",
"js_challenge_error": "Помилка перевірки!",
"js_challenge_error_msg": "Не вдалося визначити алгоритм перевірки. Спробуйте оновити сторінку.",
"js_calculating_difficulty": "Обчислення...<br/>Складність:",
"js_speed": "Швидкість:",
"js_verification_longer": "Перевірка триває довше, ніж очікувалося. Будь ласка, не оновлюйте сторінку.",
"js_success": "Успіх!",
"js_done_took": "Готово! Знадобилося",
"js_iterations": "ітерацій",
"js_finished_reading": "Читання завершено, продовжити →",
"js_calculation_error": "Помилка обчислення!",
"js_calculation_error_msg": "Не вдалося обчислити перевірку:"
}

View File

@@ -27,10 +27,11 @@ func TestLocalizationService(t *testing.T) {
"pt-BR": "Carregando...", "pt-BR": "Carregando...",
"tr": "Yükleniyor...", "tr": "Yükleniyor...",
"ru": "Загрузка...", "ru": "Загрузка...",
"uk": "Завантаження...",
"vi": "Đang nạp...", "vi": "Đang nạp...",
"zh-CN": "加载中...", "zh-CN": "加载中...",
"zh-TW": "載入中...", "zh-TW": "載入中...",
"sv" : "Laddar...", "sv": "Laddar...",
} }
var keys []string var keys []string

View File

@@ -51,6 +51,11 @@ func (rac *RemoteAddrChecker) Check(r *http.Request) (bool, error) {
return false, fmt.Errorf("%w: %s is not an IP address: %w", ErrMisconfiguration, host, err) return false, fmt.Errorf("%w: %s is not an IP address: %w", ErrMisconfiguration, host, err)
} }
// Convert IPv4-mapped IPv6 addresses to IPv4
if addr.Is6() && addr.Is4In6() {
addr = addr.Unmap()
}
return rac.prefixTable.Contains(addr), nil return rac.prefixTable.Contains(addr), nil
} }

View File

@@ -21,6 +21,20 @@ func TestRemoteAddrChecker(t *testing.T) {
ok: true, ok: true,
err: nil, err: nil,
}, },
{
name: "match_ipv4_in_ipv6",
cidrs: []string{"0.0.0.0/0"},
ip: "::ffff:1.1.1.1",
ok: true,
err: nil,
},
{
name: "match_ipv4_in_ipv6_hex",
cidrs: []string{"0.0.0.0/0"},
ip: "::ffff:101:101",
ok: true,
err: nil,
},
{ {
name: "match_ipv6", name: "match_ipv6",
cidrs: []string{"::/0"}, cidrs: []string{"::/0"},

View File

@@ -8,9 +8,9 @@ import (
func TestASNsValid(t *testing.T) { func TestASNsValid(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string
input *ASNs
err error err error
input *ASNs
name string
}{ }{
{ {
name: "basic valid", name: "basic valid",

View File

@@ -62,13 +62,11 @@ type BotConfig struct {
Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"` Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"`
Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"` Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"`
Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"` Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"`
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
Name string `json:"name" yaml:"name"` Name string `json:"name" yaml:"name"`
Action Rule `json:"action" yaml:"action"` Action Rule `json:"action" yaml:"action"`
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"` RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
// Thoth features
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
} }
func (b BotConfig) Zero() bool { func (b BotConfig) Zero() bool {
@@ -324,13 +322,13 @@ func (sc StatusCodes) Valid() error {
} }
type fileConfig struct { type fileConfig struct {
Bots []BotOrImport `json:"bots"`
DNSBL bool `json:"dnsbl"`
OpenGraph openGraphFileConfig `json:"openGraph,omitempty"` OpenGraph openGraphFileConfig `json:"openGraph,omitempty"`
Impressum *Impressum `json:"impressum,omitempty"` Impressum *Impressum `json:"impressum,omitempty"`
StatusCodes StatusCodes `json:"status_codes"`
Store *Store `json:"store"` Store *Store `json:"store"`
Bots []BotOrImport `json:"bots"`
Thresholds []Threshold `json:"thresholds"` Thresholds []Threshold `json:"thresholds"`
StatusCodes StatusCodes `json:"status_codes"`
DNSBL bool `json:"dnsbl"`
} }
func (c *fileConfig) Valid() error { func (c *fileConfig) Valid() error {
@@ -462,13 +460,13 @@ func Load(fin io.Reader, fname string) (*Config, error) {
} }
type Config struct { type Config struct {
Impressum *Impressum
Store *Store
OpenGraph OpenGraph
Bots []BotConfig Bots []BotConfig
Thresholds []Threshold Thresholds []Threshold
DNSBL bool
Impressum *Impressum
OpenGraph OpenGraph
StatusCodes StatusCodes StatusCodes StatusCodes
Store *Store DNSBL bool
} }
func (c Config) Valid() error { func (c Config) Valid() error {

View File

@@ -15,9 +15,9 @@ func p[V any](v V) *V { return &v }
func TestBotValid(t *testing.T) { func TestBotValid(t *testing.T) {
var tests = []struct { var tests = []struct {
bot BotConfig
err error err error
name string name string
bot BotConfig
}{ }{
{ {
name: "simple user agent", name: "simple user agent",

View File

@@ -11,10 +11,10 @@ import (
func TestExpressionOrListMarshalJSON(t *testing.T) { func TestExpressionOrListMarshalJSON(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string
input *ExpressionOrList
output []byte
err error err error
input *ExpressionOrList
name string
output []byte
}{ }{
{ {
name: "single expression", name: "single expression",
@@ -74,10 +74,10 @@ func TestExpressionOrListMarshalJSON(t *testing.T) {
func TestExpressionOrListMarshalYAML(t *testing.T) { func TestExpressionOrListMarshalYAML(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string
input *ExpressionOrList
output []byte
err error err error
input *ExpressionOrList
name string
output []byte
}{ }{
{ {
name: "single expression", name: "single expression",
@@ -217,8 +217,8 @@ func TestExpressionOrListUnmarshalJSON(t *testing.T) {
func TestExpressionOrListString(t *testing.T) { func TestExpressionOrListString(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string name string
in ExpressionOrList
out string out string
in ExpressionOrList
}{ }{
{ {
name: "single expression", name: "single expression",

View File

@@ -7,9 +7,9 @@ import (
func TestGeoIPValid(t *testing.T) { func TestGeoIPValid(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string
input *GeoIP
err error err error
input *GeoIP
name string
}{ }{
{ {
name: "basic valid", name: "basic valid",

View File

@@ -8,9 +8,9 @@ import (
func TestImpressumValid(t *testing.T) { func TestImpressumValid(t *testing.T) {
for _, cs := range []struct { for _, cs := range []struct {
name string
inp Impressum
err error err error
inp Impressum
name string
}{ }{
{ {
name: "basic happy path", name: "basic happy path",

View File

@@ -13,17 +13,17 @@ var (
) )
type openGraphFileConfig struct { type openGraphFileConfig struct {
Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
TimeToLive string `json:"ttl" yaml:"ttl"`
Enabled bool `json:"enabled" yaml:"enabled"` Enabled bool `json:"enabled" yaml:"enabled"`
ConsiderHost bool `json:"considerHost" yaml:"enabled"` ConsiderHost bool `json:"considerHost" yaml:"enabled"`
TimeToLive string `json:"ttl" yaml:"ttl"`
Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
} }
type OpenGraph struct { type OpenGraph struct {
Enabled bool `json:"enabled" yaml:"enabled"`
ConsiderHost bool `json:"considerHost" yaml:"enabled"`
Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"` Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
TimeToLive time.Duration `json:"ttl" yaml:"ttl"` TimeToLive time.Duration `json:"ttl" yaml:"ttl"`
Enabled bool `json:"enabled" yaml:"enabled"`
ConsiderHost bool `json:"considerHost" yaml:"enabled"`
} }
func (og *openGraphFileConfig) Valid() error { func (og *openGraphFileConfig) Valid() error {

View File

@@ -7,9 +7,9 @@ import (
func TestOpenGraphFileConfigValid(t *testing.T) { func TestOpenGraphFileConfigValid(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string
input *openGraphFileConfig
err error err error
input *openGraphFileConfig
name string
}{ }{
{ {
name: "basic happy path", name: "basic happy path",

View File

@@ -12,9 +12,9 @@ import (
func TestStoreValid(t *testing.T) { func TestStoreValid(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
err error
name string name string
input config.Store input config.Store
err error
}{ }{
{ {
name: "no backend", name: "no backend",

View File

@@ -31,10 +31,10 @@ var (
) )
type Threshold struct { type Threshold struct {
Name string `json:"name" yaml:"name"`
Expression *ExpressionOrList `json:"expression" yaml:"expression"` Expression *ExpressionOrList `json:"expression" yaml:"expression"`
Action Rule `json:"action" yaml:"action"`
Challenge *ChallengeRules `json:"challenge" yaml:"challenge"` Challenge *ChallengeRules `json:"challenge" yaml:"challenge"`
Name string `json:"name" yaml:"name"`
Action Rule `json:"action" yaml:"action"`
} }
func (t Threshold) Valid() error { func (t Threshold) Valid() error {

View File

@@ -10,9 +10,9 @@ import (
func TestThresholdValid(t *testing.T) { func TestThresholdValid(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string
input *Threshold
err error err error
input *Threshold
name string
}{ }{
{ {
name: "basic allow", name: "basic allow",

View File

@@ -14,11 +14,11 @@ func TestBotEnvironment(t *testing.T) {
t.Run("missingHeader", func(t *testing.T) { t.Run("missingHeader", func(t *testing.T) {
tests := []struct { tests := []struct {
headers map[string]string
name string name string
expression string expression string
headers map[string]string
expected types.Bool
description string description string
expected types.Bool
}{ }{
{ {
name: "missing-header", name: "missing-header",
@@ -167,10 +167,10 @@ func TestBotEnvironment(t *testing.T) {
t.Run("invalid", func(t *testing.T) { t.Run("invalid", func(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
env any
name string name string
description string description string
expression string expression string
env any
wantFailCompile bool wantFailCompile bool
wantFailEval bool wantFailEval bool
}{ }{
@@ -244,11 +244,11 @@ func TestThresholdEnvironment(t *testing.T) {
} }
tests := []struct { tests := []struct {
variables map[string]interface{}
name string name string
expression string expression string
variables map[string]interface{}
expected types.Bool
description string description string
expected types.Bool
shouldCompile bool shouldCompile bool
}{ }{
{ {

View File

@@ -10,8 +10,8 @@ import (
) )
type loadAvg struct { type loadAvg struct {
lock sync.RWMutex
data *load.AvgStat data *load.AvgStat
lock sync.RWMutex
} }
func (l *loadAvg) updateThread(ctx context.Context) { func (l *loadAvg) updateThread(ctx context.Context) {

View File

@@ -29,16 +29,15 @@ var (
) )
type ParsedConfig struct { type ParsedConfig struct {
orig *config.Config Store store.Interface
orig *config.Config
Bots []Bot
Thresholds []*Threshold
DNSBL bool
Impressum *config.Impressum Impressum *config.Impressum
OpenGraph config.OpenGraph OpenGraph config.OpenGraph
DefaultDifficulty int Bots []Bot
Thresholds []*Threshold
StatusCodes config.StatusCodes StatusCodes config.StatusCodes
Store store.Interface DefaultDifficulty int
DNSBL bool
} }
func newParsedConfig(orig *config.Config) *ParsedConfig { func newParsedConfig(orig *config.Config) *ParsedConfig {

View File

@@ -13,7 +13,7 @@ import (
func TestRedirectSecurity(t *testing.T) { func TestRedirectSecurity(t *testing.T) {
tests := []struct { tests := []struct {
name string reqHost string
testType string // "constructRedirectURL", "serveHTTPNext", "renderIndex" testType string // "constructRedirectURL", "serveHTTPNext", "renderIndex"
// For constructRedirectURL tests // For constructRedirectURL tests
@@ -23,17 +23,16 @@ func TestRedirectSecurity(t *testing.T) {
// For serveHTTPNext tests // For serveHTTPNext tests
redirParam string redirParam string
reqHost string name string
errorContains string
expectedStatus int
// For renderIndex tests // For renderIndex tests
returnHTTPStatusOnly bool returnHTTPStatusOnly bool
shouldError bool
// Expected results shouldNotRedirect bool
expectedStatus int shouldBlock bool
shouldError bool
shouldNotRedirect bool
shouldBlock bool
errorContains string
}{ }{
// constructRedirectURL tests - X-Forwarded-Proto validation // constructRedirectURL tests - X-Forwarded-Proto validation
{ {

View File

@@ -17,9 +17,9 @@ func TestFactoryValid(t *testing.T) {
t.Run("invalid config", func(t *testing.T) { t.Run("invalid config", func(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
err error
name string name string
cfg Config cfg Config
err error
}{ }{
{ {
name: "missing path", name: "missing path",

View File

@@ -88,8 +88,8 @@ func (Factory) Valid(data json.RawMessage) error {
} }
type Config struct { type Config struct {
PathStyle bool `json:"pathStyle"`
BucketName string `json:"bucketName"` BucketName string `json:"bucketName"`
PathStyle bool `json:"pathStyle"`
} }
func (c Config) Valid() error { func (c Config) Valid() error {

View File

@@ -17,10 +17,10 @@ import (
// mockS3 is an in-memory mock of the methods we use. // mockS3 is an in-memory mock of the methods we use.
type mockS3 struct { type mockS3 struct {
mu sync.RWMutex
bucket string
data map[string][]byte data map[string][]byte
meta map[string]map[string]string meta map[string]map[string]string
bucket string
mu sync.RWMutex
} }
func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {

View File

@@ -21,9 +21,9 @@ func Common(t *testing.T, f store.Factory, config json.RawMessage) {
} }
for _, tt := range []struct { for _, tt := range []struct {
name string
doer func(t *testing.T, s store.Interface) error
err error err error
doer func(t *testing.T, s store.Interface) error
name string
}{ }{
{ {
name: "basic get set delete", name: "basic get set delete",

View File

@@ -5,80 +5,98 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9" valkey "github.com/redis/go-redis/v9"
) "github.com/redis/go-redis/v9/maintnotifications"
var (
ErrNoURL = errors.New("valkey.Config: no URL defined")
ErrBadURL = errors.New("valkey.Config: URL is invalid")
) )
func init() { func init() {
store.Register("valkey", Factory{}) store.Register("valkey", Factory{})
} }
type Factory struct{} // Errors kept as-is so other code/tests still pass.
var (
func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) { ErrNoURL = errors.New("valkey.Config: no URL defined")
var config Config ErrBadURL = errors.New("valkey.Config: URL is invalid")
)
if err := json.Unmarshal([]byte(data), &config); err != nil {
return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
}
if err := config.Valid(); err != nil {
return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
}
opts, err := valkey.ParseURL(config.URL)
if err != nil {
return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
}
rdb := valkey.NewClient(opts)
if _, err := rdb.Ping(ctx).Result(); err != nil {
return nil, fmt.Errorf("can't ping valkey instance: %w", err)
}
return &Store{
rdb: rdb,
}, nil
}
func (Factory) Valid(data json.RawMessage) error {
var config Config
if err := json.Unmarshal([]byte(data), &config); err != nil {
return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
}
if err := config.Valid(); err != nil {
return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
}
return nil
}
// Config is what Anubis unmarshals from the "parameters" JSON.
type Config struct { type Config struct {
URL string `json:"url"` URL string `json:"url"`
Cluster bool `json:"cluster,omitempty"`
} }
func (c Config) Valid() error { func (c Config) Valid() error {
var errs []error
if c.URL == "" { if c.URL == "" {
errs = append(errs, ErrNoURL) return ErrNoURL
} }
// Just validate that it's a valid Redis URL.
if _, err := valkey.ParseURL(c.URL); err != nil { if _, err := valkey.ParseURL(c.URL); err != nil {
errs = append(errs, ErrBadURL) return fmt.Errorf("%w: %v", ErrBadURL, err)
}
if len(errs) != 0 {
return fmt.Errorf("valkey.Config: invalid config: %w", errors.Join(errs...))
} }
return nil return nil
} }
// redisClient is satisfied by *valkey.Client and *valkey.ClusterClient.
type redisClient interface {
Get(ctx context.Context, key string) *valkey.StringCmd
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *valkey.StatusCmd
Del(ctx context.Context, keys ...string) *valkey.IntCmd
Ping(ctx context.Context) *valkey.StatusCmd
}
type Factory struct{}
func (Factory) Valid(data json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return err
}
return cfg.Valid()
}
func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if err := cfg.Valid(); err != nil {
return nil, err
}
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
var client redisClient
if cfg.Cluster {
// Cluster mode: use the parsed Addr as the seed node.
clusterOpts := &valkey.ClusterOptions{
Addrs: []string{opts.Addr},
// Explicitly disable maintenance notifications
// This prevents the client from sending CLIENT MAINT_NOTIFICATIONS ON
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
}
client = valkey.NewClusterClient(clusterOpts)
} else {
opts.MaintNotificationsConfig = &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
}
client = valkey.NewClient(opts)
}
// Optional but nice: fail fast if the cluster/single node is unreachable.
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("valkey.Factory: ping failed: %w", err)
}
return &Store{client: client}, nil
}

View File

@@ -2,52 +2,46 @@ package valkey
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/TecharoHQ/anubis/lib/store" "github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9" valkey "github.com/redis/go-redis/v9"
) )
// Store implements store.Interface on top of Redis/Valkey.
type Store struct { type Store struct {
rdb *valkey.Client client redisClient
} }
func (s *Store) Delete(ctx context.Context, key string) error { var _ store.Interface = (*Store)(nil)
n, err := s.rdb.Del(ctx, key).Result()
if err != nil {
return fmt.Errorf("can't delete from valkey: %w", err)
}
switch n {
case 0:
return fmt.Errorf("%w: %d key(s) deleted", store.ErrNotFound, n)
default:
return nil
}
}
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
result, err := s.rdb.Get(ctx, key).Result() cmd := s.client.Get(ctx, key)
if err != nil { if err := cmd.Err(); err != nil {
if valkey.HasErrorPrefix(err, "redis: nil") { if err == valkey.Nil {
return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err) return nil, store.ErrNotFound
} }
return nil, err
return nil, fmt.Errorf("can't fetch from valkey: %w", err)
} }
return cmd.Bytes()
return []byte(result), nil
} }
func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error { func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
if _, err := s.rdb.Set(ctx, key, string(value), expiry).Result(); err != nil { return s.client.Set(ctx, key, value, expiry).Err()
return fmt.Errorf("can't set %q in valkey: %w", key, err) }
}
func (s *Store) Delete(ctx context.Context, key string) error {
res := s.client.Del(ctx, key)
if err := res.Err(); err != nil {
return err
}
if n, _ := res.Result(); n == 0 {
return store.ErrNotFound
}
return nil return nil
} }
// IsPersistent tells Anubis this backend is “real” storage, not in-memory.
func (s *Store) IsPersistent() bool { func (s *Store) IsPersistent() bool {
return true return true
} }

View File

@@ -2,20 +2,14 @@ package valkey
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"testing" "testing"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/store/storetest" "github.com/TecharoHQ/anubis/lib/store/storetest"
"github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
) )
func init() {
internal.UnbreakDocker()
}
func TestImpl(t *testing.T) { func TestImpl(t *testing.T) {
if os.Getenv("DONT_USE_NETWORK") != "" { if os.Getenv("DONT_USE_NETWORK") != "" {
t.Skip("test requires network egress") t.Skip("test requires network egress")
@@ -24,26 +18,26 @@ func TestImpl(t *testing.T) {
testcontainers.SkipIfProviderIsNotHealthy(t) testcontainers.SkipIfProviderIsNotHealthy(t)
req := testcontainers.ContainerRequest{ valkeyC, err := testcontainers.Run(
Image: "valkey/valkey:8", t.Context(), "valkey/valkey:8",
WaitingFor: wait.ForLog("Ready to accept connections"), testcontainers.WithExposedPorts("6379/tcp"),
} testcontainers.WithWaitStrategy(
valkeyC, err := testcontainers.GenericContainer(t.Context(), testcontainers.GenericContainerRequest{ wait.ForListeningPort("6379/tcp"),
ContainerRequest: req, wait.ForLog("Ready to accept connections"),
Started: true, ),
}) )
testcontainers.CleanupContainer(t, valkeyC) testcontainers.CleanupContainer(t, valkeyC)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
containerIP, err := valkeyC.ContainerIP(t.Context()) endpoint, err := valkeyC.PortEndpoint(t.Context(), "6379/tcp", "redis")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
data, err := json.Marshal(Config{ data, err := json.Marshal(Config{
URL: fmt.Sprintf("redis://%s:6379/0", containerIP), URL: endpoint,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

0
run/openrc/anubis.initd Normal file → Executable file
View File