From e53dd5a37a59156f4b6d290c5368eb63a5d9ba1b Mon Sep 17 00:00:00 2001 From: Jason Cameron Date: Sat, 6 Sep 2025 22:01:38 -0400 Subject: [PATCH] feat(storage): add IsPersistent method and validation warning for signing keys --- cmd/anubis/main.go | 10 ++++++++++ docs/docs/CHANGELOG.md | 1 + docs/docs/admin/installation.mdx | 2 +- lib/store/bbolt/bbolt.go | 9 ++++++--- lib/store/interface.go | 9 +++++++++ lib/store/memory/memory.go | 4 ++++ lib/store/valkey/valkey.go | 4 ++++ 7 files changed, 35 insertions(+), 4 deletions(-) diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index c3fd4c7b..e1bd36f0 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -317,6 +317,16 @@ func main() { log.Fatalf("can't parse policy file: %v", err) } + // Warn if persistent storage is used without a configured signing key + if policy.Store.IsPersistent() { + if *hs512Secret == "" && *ed25519PrivateKeyHex == "" && *ed25519PrivateKeyHexFile == "" { + slog.Warn("[misconfiguration] persistent storage backend is configured, but no private key is set. " + + "Challenges will be invalidated when Anubis restarts. " + + "Set HS512_SECRET, ED25519_PRIVATE_KEY_HEX, or ED25519_PRIVATE_KEY_HEX_FILE to ensure challenges survive service restarts. " + + "See: https://anubis.techaro.lol/docs/admin/installation#key-generation") + } + } + ruleErrorIDs := make(map[string]string) for _, rule := range policy.Bots { if rule.Action != config.RuleDeny { diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 16b15029..0e0b288c 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Document missing environment variables in installation guide: `SLOG_LEVEL`, `COOKIE_PREFIX`, `FORCED_LANGUAGE`, and `TARGET_DISABLE_KEEPALIVE` ([#1086](https://github.com/TecharoHQ/anubis/pull/1086)) +- Add validation warning when persistent storage is used without setting signing keys diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index 12197951..0e305ddf 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -70,7 +70,7 @@ Anubis uses these environment variables for configuration: | `COOKIE_PREFIX` | `anubis-cookie` | The prefix used for browser cookies created by Anubis. Useful for customization or avoiding conflicts with other applications. | | `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false | | `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. | -| `ED25519_PRIVATE_KEY_HEX` | unset | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. | +| `ED25519_PRIVATE_KEY_HEX` | unset | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. See below for details. | | `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. | | `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. | | `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. | diff --git a/lib/store/bbolt/bbolt.go b/lib/store/bbolt/bbolt.go index 8e575c7f..eb7b3401 100644 --- a/lib/store/bbolt/bbolt.go +++ b/lib/store/bbolt/bbolt.go @@ -11,10 +11,9 @@ import ( "go.etcd.io/bbolt" ) -// Sentinel error values used for testing and in admin-visible error messages. +// Sentinel error value used for testing and in admin-visible error messages. var ( - ErrBucketDoesNotExist = errors.New("bbolt: bucket does not exist") - ErrNotExists = errors.New("bbolt: value does not exist in store") + ErrNotExists = errors.New("bbolt: value does not exist in store") ) // Store implements store.Interface backed by bbolt[1]. @@ -150,6 +149,10 @@ func (s *Store) cleanup(ctx context.Context) error { }) } +func (s *Store) IsPersistent() bool { + return true +} + func (s *Store) cleanupThread(ctx context.Context) { t := time.NewTicker(time.Hour) defer t.Stop() diff --git a/lib/store/interface.go b/lib/store/interface.go index 496a872c..7a20e8a0 100644 --- a/lib/store/interface.go +++ b/lib/store/interface.go @@ -37,6 +37,11 @@ type Interface interface { // Set puts a value into the store that expires according to its expiry. Set(ctx context.Context, key string, value []byte, expiry time.Duration) error + + // IsPersistent returns true if this storage backend persists data across + // service restarts (e.g., bbolt, valkey). Returns false for volatile storage + // like in-memory backends. + IsPersistent() bool } func z[T any]() T { return *new(T) } @@ -88,3 +93,7 @@ func (j *JSON[T]) Set(ctx context.Context, key string, value T, expiry time.Dura return nil } + +func (j *JSON[T]) IsPersistent() bool { + return j.Underlying.IsPersistent() +} diff --git a/lib/store/memory/memory.go b/lib/store/memory/memory.go index 116a433a..a85f8998 100644 --- a/lib/store/memory/memory.go +++ b/lib/store/memory/memory.go @@ -48,6 +48,10 @@ func (i *impl) Set(_ context.Context, key string, value []byte, expiry time.Dura return nil } +func (i *impl) IsPersistent() bool { + return false +} + func (i *impl) cleanupThread(ctx context.Context) { t := time.NewTicker(5 * time.Minute) defer t.Stop() diff --git a/lib/store/valkey/valkey.go b/lib/store/valkey/valkey.go index fd7a4e7f..11f43740 100644 --- a/lib/store/valkey/valkey.go +++ b/lib/store/valkey/valkey.go @@ -47,3 +47,7 @@ func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.D return nil } + +func (s *Store) IsPersistent() bool { + return true +}