Compare commits

..

7 Commits

Author SHA1 Message Date
Xe Iaso
20c6b7be42 feat(expressions): add contentLength to bot expressions
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-15 01:35:01 +00:00
Xe Iaso
88b3e457ee docs: update BotStopper docs based on new features
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-14 20:16:43 +00:00
Xe Iaso
bb2b113b63 ci(ssh): don't print uname -av output (#1114)
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-14 03:03:46 +00:00
Xe Iaso
6c283d0cd9 ci: add aarch64 for ssh CI (#1112)
* ci: add aarch64 for ssh CI

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

* ci: better comment aile and t-elos' roles

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

* ci: fix aile

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

* ci: update ssh known hosts secret

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

* ci(ssh): replace raw connection strings with arch-quirks

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

* ci(ssh): disable this check in PRs again

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-09-14 00:15:23 +00:00
agoujot
0037e214a1 add link to preact in challenge list (#1111)
Preact was added in 1.22, but it currently isn't listed in the "Challenges" page.

Signed-off-by: agoujot <145840578+agoujot@users.noreply.github.com>
2025-09-13 17:31:36 -04:00
Valentin Lab
29ae2a4b87 feat: fallback to SameSite Lax mode if cookie is not secure (#1105)
Also, will allow to set cookie `SameSite` mode on command line or
environment. Note that `None` mode will be forced to ``Lax`` if
cookie is set to not be secure.

Signed-off-by: Valentin Lab <valentin.lab@kalysto.org>
2025-09-13 10:56:54 +00:00
Xe Iaso
401e18f29f feat(store/bbolt): implement actor pattern (#1107)
* feat(store/bbolt): implement actor pattern

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

* docs(internal/actorify): document package

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

* Update metadata

check-spelling run (pull_request) for Xe/actorify

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
2025-09-12 18:35:22 +00:00
33 changed files with 566 additions and 524 deletions

View File

@@ -1,4 +1,7 @@
acs
Actorified
actorifiedstore
actorify
Aibrew
alibaba
alrest
@@ -157,6 +160,7 @@ ifm
Imagesift
imgproxy
impressum
inbox
inp
internets
IPTo

View File

@@ -12,12 +12,14 @@ permissions:
jobs:
ssh:
if: github.repository == 'TecharoHQ/anubis'
runs-on: ubuntu-24.04
runs-on: alrest-techarohq
strategy:
matrix:
host:
- ubuntu@riscv64.techaro.lol
- ci@ppc64le.techaro.lol
- riscv64
- ppc64le
- aarch64-4k
- aarch64-16k
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

View File

@@ -31,7 +31,6 @@ import (
"github.com/TecharoHQ/anubis/data"
"github.com/TecharoHQ/anubis/internal"
libanubis "github.com/TecharoHQ/anubis/lib"
"github.com/TecharoHQ/anubis/lib/logging"
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/thoth"
@@ -57,6 +56,7 @@ var (
forcedLanguage = flag.String("forced-language", "", "if set, this language is being used instead of the one from the request's Accept-Language header")
hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set")
cookieSecure = flag.Bool("cookie-secure", true, "if true, sets the secure flag on Anubis cookies")
cookieSameSite = flag.String("cookie-same-site", "None", "sets the same site option on Anubis cookies, will auto-downgrade None to Lax if cookie-secure is false. Valid values are None, Lax, Strict, and Default.")
ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex")
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
@@ -144,6 +144,22 @@ func parseBindNetFromAddr(address string) (string, string) {
return "", address
}
func parseSameSite(s string) (http.SameSite) {
switch strings.ToLower(s) {
case "none":
return http.SameSiteNoneMode
case "lax":
return http.SameSiteLaxMode
case "strict":
return http.SameSiteStrictMode
case "default":
return http.SameSiteDefaultMode
default:
log.Fatalf("invalid cookie same-site mode: %s, valid values are None, Lax, Strict, and Default", s)
}
return http.SameSiteDefaultMode
}
func setupListener(network string, address string) (net.Listener, string) {
formattedAddress := ""
@@ -251,7 +267,7 @@ func main() {
return
}
slog.SetDefault(slog.New(logging.Init(*slogLevel)))
internal.InitSlog(*slogLevel)
internal.SetHealth("anubis", healthv1.HealthCheckResponse_NOT_SERVING)
if *healthcheck {
@@ -433,6 +449,7 @@ func main() {
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
CookieSameSite: parseSameSite(*cookieSameSite),
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
DifficultyInJWT: *difficultyInJWT,
@@ -448,10 +465,7 @@ func main() {
h = internal.XForwardedForUpdate(*xffStripPrivate, h)
h = internal.JA4H(h)
srv := http.Server{
Handler: h,
ErrorLog: logging.StdlibLogger(s.GetLogger("http-server").Handler(), slog.LevelDebug),
}
srv := http.Server{Handler: h, ErrorLog: internal.GetFilteredHTTPLogger()}
listener, listenerUrl := setupListener(*bindNetwork, *bind)
slog.Info(
"listening",
@@ -511,10 +525,7 @@ func metricsServer(ctx context.Context, done func()) {
}
})
srv := http.Server{
Handler: mux,
ErrorLog: logging.StdlibLogger(slog.With("subsystem", "metrics-server").Handler(), slog.LevelDebug),
}
srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()}
listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind)
slog.Debug("listening for metrics", "url", metricsUrl)

View File

@@ -10,7 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/TecharoHQ/anubis/lib/logging"
"github.com/TecharoHQ/anubis/internal"
"github.com/facebookgo/flagenv"
)
@@ -28,7 +28,7 @@ func main() {
flagenv.Parse()
flag.Parse()
slog.SetDefault(slog.New(logging.Init(*slogLevel)))
internal.InitSlog(*slogLevel)
koDockerRepo := strings.TrimSuffix(*dockerRepo, "/"+filepath.Base(*dockerRepo))

View File

@@ -132,28 +132,6 @@ dnsbl: false
# <!-- ... -->
# Logging settings for Anubis
logging:
# CEL log filters. Note that this is a very powerful feature and it is very easy to get
# yourself into trouble with this. Avoid using log filters unless you are running into
# circumstances like https://github.com/TecharoHQ/anubis/issues/942. This has a nonzero
# impact on logging, which spirals out into a more than zero impact on Anubis'
# performance and memory usage.
filters:
# Every filter must have a name and an expression. You can use the same expression
# syntax as you can with bots or thresholds.
#
# If the expression returns `true`, then the log line is filtered _out_.
- name: "http-stdlib"
# Log lines where the message starts with "http:" are filtered out.
expression: msg.startsWith("http:")
- name: "context-canceled"
# Log lines relating to context cancellation are filtered out.
expression: msg.contains("context canceled")
- name: "http-pipelining"
# Log lines relating to HTTP/1.1 pipelining being improperly handled are filtered out.
expression: msg.contains("Unsolicited response received on idle HTTP channel")
# Open Graph passthrough configuration, see here for more information:
# https://anubis.techaro.lol/docs/admin/configuration/open-graph/
openGraph:

View File

@@ -13,14 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: -->
- Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103))
- [Log filtering](./admin/configuration/expressions.mdx#log-filtering) rules have been added. This allows users to write custom log filtering logic.
- 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
- Fixed `robots2policy` to properly group consecutive user agents into `any:` instead of only processing the last one ([#925](https://github.com/TecharoHQ/anubis/pull/925))
- Add `contentLength` variable to bot expressions.
- Add `COOKIE_SAME_SITE_MODE` to force anubis cookies SameSite value, and downgrade automatically from `None` to `Lax` if cookie is insecure.
- Fix lock convoy problem in decaymap ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).
- Fix lock convoy problem in bbolt by implementing the actor pattern ([#1103](https://github.com/TecharoHQ/anubis/issues/1103)).
- 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.
- Fixed `robots2policy` to properly group consecutive user agents into `any:` instead of only processing the last one ([#925](https://github.com/TecharoHQ/anubis/pull/925)).
- Add the [`s3api` storage backend](./admin/policies.mdx#s3api) to allow Anubis to use S3 API compatible object storage as its storage backend.
- Make `cmd/containerbuild` support commas for separating elements of the `--docker-tags` argument as well as newlines.
- Add the `DIFFICULTY_IN_JWT` option, which allows one to add the `difficulty` field in the JWT claims which indicates the difficulty of the token ([#1063](https://github.com/TecharoHQ/anubis/pull/1063))
- Add the `DIFFICULTY_IN_JWT` option, which allows one to add the `difficulty` field in the JWT claims which indicates the difficulty of the token ([#1063](https://github.com/TecharoHQ/anubis/pull/1063)).
- Ported the client-side JS to TypeScript to avoid egregious errors in the future.
- Fixes concurrency problems with very old browsers ([#1082](https://github.com/TecharoHQ/anubis/issues/1082)).

View File

@@ -126,6 +126,34 @@ Your directory tree should look like this, assuming your data is in `./your_fold
For an example directory tree using some off-the-shelf images the Tango icon set, see the [testdata](https://github.com/TecharoHQ/botstopper/tree/main/testdata/static/img) folder.
### Header-based overlay dispatch
If you run BotStopper in a multi-tenant environment where each tenant needs its own branding, BotStopper supports the ability to use request header values to direct asset reads to different folders under your `OVERLAY_FOLDER`. One of the most common ways to do this is based on the HTTP Host of the request. For example, if you set `ASSET_LOOKUP_HEADER=Host` in BotStopper's environment:
```text
$OVERLAY_FOLDER
├── static
│ ├── css
│ │ ├── custom.css
│ │ └── eyesore.css
│ └── img
│ ├── happy.webp
│ ├── pensive.webp
│ └── reject.webp
└── test.anubis.techaro.lol
└── static
├── css
│ └── custom.css
└── img
├── happy.webp
├── pensive.webp
└── reject.webp
```
Requests to `test.anubis.techaro.lol` will load assets in `$OVERLAY_FOLDER/test.anubis.techaro.lol/static` and all other requests will load them from `$OVERLAY_FOLDER/static`.
For an example, look at [the testdata folder in the BotStopper repo](https://github.com/TecharoHQ/botstopper/tree/main/testdata).
### Custom CSS
CSS customization is done mainly with CSS variables. View [the example custom CSS file](https://github.com/TecharoHQ/botstopper/blob/main/testdata/static/css/custom.css) for more information about what can be customized.
@@ -199,7 +227,9 @@ $ du -hs *
## Custom HTML templates
If you need to completely control the HTML layout of all Anubis pages, you can customize the entire page with `USE_TEMPLATES=true`. This uses Go's standard library [html/template](https://pkg.go.dev/html/template) package to template HTML responses. In order to use this, you must define the following templates:
If you need to completely control the HTML layout of all Anubis pages, you can customize the entire page with `USE_TEMPLATES=true`. This uses Go's standard library [html/template](https://pkg.go.dev/html/template) package to template HTML responses. Your templates can contain whatever HTML you want. The only catch is that you MUST include `{{ .Head }}` in the `<head>` element for challenge pages, and you MUST include `{{ .Body }}` in the `<body>` element for all pages.
In order to use this, you must define the following templates:
| Template path | Usage |
| :----------------------------------------- | :---------------------------------------------- |
@@ -207,6 +237,12 @@ If you need to completely control the HTML layout of all Anubis pages, you can c
| `$OVERLAY_FOLDER/templates/error.tmpl` | Error pages |
| `$OVERLAY_FOLDER/templates/impressum.tmpl` | [Impressum](./configuration/impressum.mdx) page |
:::note
Currently HTML templates don't work together with [Header-based overlay dispatch](#header-based-overlay-dispatch). This is a known issue that will be fixed soon. If you enable header-based overlay dispatch, BotStopper will use the global `templates` folder instead of using the templates present in the overlay.
:::
Here are minimal (but working) examples for each template:
<details>

View File

@@ -3,6 +3,7 @@
Anubis supports multiple challenge methods:
- [Meta Refresh](./metarefresh.mdx)
- [Preact](./preact.mdx)
- [Proof of Work](./proof-of-work.mdx)
Read the documentation to know which method is best for you.

View File

@@ -99,14 +99,11 @@ For this rule, if a request comes in matching [the signature of the `go get` com
Anubis exposes the following variables to expressions:
### Bot expressions
Bot expressions are used for evaluating [bot rules](../policies.mdx#bot-policies).
| Name | Type | Explanation | Example |
| :-------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- |
| `headers` | `map[string, string]` | The [headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers) of the request being processed. | `{"User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/137.0"}` |
| `host` | `string` | The [HTTP hostname](https://web.dev/articles/url-parts#host) the request is targeted to. | `anubis.techaro.lol` |
| `contentLength` | `int64` | The numerical value of the `Content-Length` header. |
| `load_1m` | `double` | The current system load average over the last one minute. This is useful for making [load-based checks](#using-the-system-load-average). |
| `load_5m` | `double` | The current system load average over the last five minutes. This is useful for making [load-based checks](#using-the-system-load-average). |
| `load_15m` | `double` | The current system load average over the last fifteen minutes. This is useful for making [load-based checks](#using-the-system-load-average). |
@@ -186,23 +183,6 @@ Something to keep in mind about system load average is that it is not aware of t
Also keep in mind that this does not account for other kinds of latency like I/O latency. A system can have its web applications unresponsive due to high latency from a MySQL server but still have that web application server report a load near or at zero.
### Log filtering
Log filters are run on every time Anubis logs data. These are high throughput filters and should be written with care.
| Name | Type | Explanation | Example |
| :------ | :-------------------- | :----------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `time` | Timestamp | The time that the log line was emitted. | `2025-08-18T06:45:38-04:00` |
| `msg` | `string` | The text-based message for the given log line. | `"invalid response"` |
| `level` | `string` | The [log level](https://pkg.go.dev/log/slog#Level) for the log message. | `"INFO"` |
| `attrs` | `map[string, string]` | The key -> value attributes for the given log line. Note that this is an expensive variable to access. | `{"err": "internal: the sun exploded"}` |
:::note
When you define a log filter, anything matching that filter is _removed_. Any remaining logs are sent through to the system journal or standard error.
:::
## Functions exposed to Anubis expressions
Anubis expressions can be augmented with the following functions:

View File

@@ -2,8 +2,16 @@
title: Setting up Anubis
---
import EnterpriseOnly from "@site/src/components/EnterpriseOnly";
import RandomKey from "@site/src/components/RandomKey";
export const EO = () => (
<>
<EnterpriseOnly link="./botstopper/" />
<div style={{ marginBottom: "0.5rem" }} />
</>
);
Anubis is meant to sit between your reverse proxy (such as Nginx or Caddy) and your target service. One instance of Anubis must be used per service you are protecting.
<center>
@@ -58,39 +66,45 @@ Currently the following settings are configurable via the policy file:
Anubis uses these environment variables for configuration:
| Environment Variable | Default value | Explanation |
| :----------------------------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BASE_PREFIX` | unset | If set, adds a global prefix to all Anubis endpoints (everything starting with `/.within.website/x/anubis/`). For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes. |
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
| `COOKIE_DOMAIN` | unset | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See this [stackoverflow explanation of cookies](https://stackoverflow.com/a/1063760) for more information.<br/><br/>Note that unlike `REDIRECT_DOMAINS`, you should never include a port number in this variable. |
| `COOKIE_DYNAMIC_DOMAIN` | false | If set to true, automatically set cookie domain fields based on the hostname of the request. EG: if you are making a request to `anubis.techaro.lol`, the Anubis cookie will be valid for any subdomain of `techaro.lol`. |
| `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_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. |
| `DIFFICULTY_IN_JWT` | `false` | If set to `true`, adds the `difficulty` field into JWT claims, which indicates the difficulty the token has been generated. This may be useful for statistics and debugging. |
| `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. **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. |
| `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. |
| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
| `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `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. |
| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). |
| `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. |
| `SLOG_LEVEL` | `INFO` | The log level for structured logging. Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR`. Set to `DEBUG` to see all requests, evaluations, and detailed diagnostic information. |
| `SOCKET_MODE` | `0770` | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets. |
| `STRIP_BASE_PREFIX` | `false` | If set to `true`, strips the base prefix from request paths when forwarding to the target server. This is useful when your target service expects to receive requests without the base prefix. For example, with `BASE_PREFIX=/foo` and `STRIP_BASE_PREFIX=true`, a request to `/foo/bar` would be forwarded to the target as `/bar`. |
| `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`. |
| `USE_REMOTE_ADDRESS` | unset | If set to `true`, Anubis will take the client's IP from the network socket. For production deployments, it is expected that a reverse proxy is used in front of Anubis, which pass the IP using headers, instead. |
| `USE_SIMPLIFIED_EXPLANATION` | false | If set to `true`, replaces the text when clicking "Why am I seeing this?" with a more simplified text for a non-tech-savvy audience. |
| `WEBMASTER_EMAIL` | unset | If set, shows a contact email address when rendering error pages. This email address will be how users can get in contact with administrators. |
| `XFF_STRIP_PRIVATE` | `true` | If set, strip private addresses from `X-Forwarded-For` headers. To unset this, you must set `XFF_STRIP_PRIVATE=false` or `--xff-strip-private=false`. |
| Environment Variable | Default value | Explanation |
| :----------------------------- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ASSET_LOOKUP_HEADER` | unset | <EO /> If set, use the contents of this header in requests when looking up custom assets in `OVERLAY_FOLDER`. See [Header-based overlay dispatch](./botstopper.mdx#header-based-overlay-dispatch) for more details. |
| `BASE_PREFIX` | unset | If set, adds a global prefix to all Anubis endpoints (everything starting with `/.within.website/x/anubis/`). For example, setting this to `/myapp` would make Anubis accessible at `/myapp/` instead of `/`. This is useful when running Anubis behind a reverse proxy that routes based on path prefixes. |
| `BIND` | `:8923` | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
| `CHALLENGE_TITLE` | unset | <EO /> If set, override the translation stack to show a custom title for challenge pages such as "Making sure your connection is secure!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. |
| `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_DYNAMIC_DOMAIN` | false | If set to true, automatically set cookie domain fields based on the hostname of the request. EG: if you are making a request to `anubis.techaro.lol`, the Anubis cookie will be valid for any subdomain of `techaro.lol`. |
| `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_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 |
| `COOKIE_SAME_SITE` | `None` | Controls the cookies [`SameSite` attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value). Allowed: `None`, `Lax`, `Strict`, `Default`. `None` permits cross-site use but modern browsers require it to be **Secure**—so if `COOKIE_SECURE=false` or you serve over plain HTTP, use `Lax` (recommended) or `Strict` or the cookie will be rejected. `Default` uses the Go runtimes `SameSiteDefaultMode`. `None` will be downgraded to `Lax` automatically if cookie is set NOT to be secure. |
| `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
| `DIFFICULTY_IN_JWT` | `false` | If set to `true`, adds the `difficulty` field into JWT claims, which indicates the difficulty the token has been generated. This may be useful for statistics and debugging. |
| `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. **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. |
| `ERROR_TITLE` | unset | <EO /> If set, override the translation stack to show a custom title for error pages such as "Something went wrong!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. |
| `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. |
| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
| `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. |
| `OVERLAY_FOLDER` | unset | <EO /> If set, treat the given path as an [overlay folder](./botstopper.mdx#custom-images-and-css), allowing you to customize CSS, fonts, images, and add other assets to BotStopper deployments. |
| `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. |
| `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). |
| `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. |
| `SLOG_LEVEL` | `INFO` | The log level for structured logging. Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR`. Set to `DEBUG` to see all requests, evaluations, and detailed diagnostic information. |
| `SOCKET_MODE` | `0770` | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets. |
| `STRIP_BASE_PREFIX` | `false` | If set to `true`, strips the base prefix from request paths when forwarding to the target server. This is useful when your target service expects to receive requests without the base prefix. For example, with `BASE_PREFIX=/foo` and `STRIP_BASE_PREFIX=true`, a request to `/foo/bar` would be forwarded to the target as `/bar`. |
| `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`. |
| `USE_REMOTE_ADDRESS` | unset | If set to `true`, Anubis will take the client's IP from the network socket. For production deployments, it is expected that a reverse proxy is used in front of Anubis, which pass the IP using headers, instead. |
| `USE_SIMPLIFIED_EXPLANATION` | false | If set to `true`, replaces the text when clicking "Why am I seeing this?" with a more simplified text for a non-tech-savvy audience. |
| `USE_TEMPLATES` | false | <EO /> If set to `true`, enable [custom HTML template support](./botstopper.mdx#custom-html-templates), allowing you to completely rewrite how BotStopper renders its HTML pages. |
| `WEBMASTER_EMAIL` | unset | If set, shows a contact email address when rendering error pages. This email address will be how users can get in contact with administrators. |
| `XFF_STRIP_PRIVATE` | `true` | If set, strip private addresses from `X-Forwarded-For` headers. To unset this, you must set `XFF_STRIP_PRIVATE=false` or `--xff-strip-private=false`. |
<details>
<summary>Advanced configuration settings</summary>

View File

@@ -123,10 +123,6 @@ remote_addresses:
Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information.
## Logging
Anubis has support for configuring log filtering using expressions. See the [log filters](./configuration/expressions.mdx#log-filters) of the [expression](./configuration/expressions.mdx) documentation for more information.
## Storage backends
Anubis needs to store temporary data in order to determine if a user is legitimate or not. Administrators should choose a storage backend based on their infrastructure needs. Each backend has its own advantages and disadvantages.

View File

@@ -0,0 +1,11 @@
import styles from './styles.module.css';
export default function EnterpriseOnly({ link }) {
return (
<a className={styles.link} href={link}>
<div className={styles.container}>
<span className={styles.label}>BotStopper Only</span>
</div>
</a>
);
}

View File

@@ -0,0 +1,18 @@
.link {
text-decoration: none;
}
.container {
background-color: #16a34a; /* green-500 */
color: #ffffff;
font-weight: 700;
padding: 0.5rem 1rem; /* py-2 px-4 */
border-radius: 9999px; /* rounded-full */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg approximation */
display: inline-flex; /* flex */
align-items: center; /* items-center */
}
.label {
line-height: 1;
}

View File

@@ -0,0 +1,107 @@
// Package actorify lets you transform a parallel operation into a serialized
// operation via the Actor pattern[1].
//
// [1]: https://en.wikipedia.org/wiki/Actor_model
package actorify
import (
"context"
"errors"
)
func z[Z any]() Z {
var z Z
return z
}
var (
// ErrActorDied is returned when the actor inbox or reply channel was closed.
ErrActorDied = errors.New("actorify: the actor inbox or reply channel was closed")
)
// Handler is a function alias for the underlying logic the Actor should call.
type Handler[Input, Output any] func(ctx context.Context, input Input) (Output, error)
// Actor is a serializing wrapper that runs a function in a background goroutine.
// Whenever the Call method is invoked, a message is sent to the actor's inbox and then
// the callee waits for a response. Depending on how busy the actor is, this may take
// a moment.
type Actor[Input, Output any] struct {
handler Handler[Input, Output]
inbox chan *message[Input, Output]
}
type message[Input, Output any] struct {
ctx context.Context
arg Input
reply chan reply[Output]
}
type reply[Output any] struct {
output Output
err error
}
// New constructs a new Actor and starts its background thread. Cancel the context and you cancel
// the Actor.
func New[Input, Output any](ctx context.Context, handler Handler[Input, Output]) *Actor[Input, Output] {
result := &Actor[Input, Output]{
handler: handler,
inbox: make(chan *message[Input, Output], 32),
}
go result.handle(ctx)
return result
}
func (a *Actor[Input, Output]) handle(ctx context.Context) {
for {
select {
case <-ctx.Done():
close(a.inbox)
return
case msg, ok := <-a.inbox:
if !ok {
if msg.reply != nil {
close(msg.reply)
}
return
}
result, err := a.handler(msg.ctx, msg.arg)
reply := reply[Output]{
output: result,
err: err,
}
msg.reply <- reply
}
}
}
// Call calls the Actor with a given Input and returns the handler's Output.
//
// This only works with unary functions by design. If you need to have more inputs, define
// a struct type to use as a container.
func (a *Actor[Input, Output]) Call(ctx context.Context, input Input) (Output, error) {
replyCh := make(chan reply[Output])
a.inbox <- &message[Input, Output]{
arg: input,
reply: replyCh,
}
select {
case reply, ok := <-replyCh:
if !ok {
return z[Output](), ErrActorDied
}
return reply.output, reply.err
case <-ctx.Done():
return z[Output](), context.Cause(ctx)
}
}

View File

@@ -1,10 +1,31 @@
package internal
import (
"fmt"
"log"
"log/slog"
"net/http"
"os"
"strings"
)
func InitSlog(level string) {
var programLevel slog.Level
if err := (&programLevel).UnmarshalText([]byte(level)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err)
programLevel = slog.LevelInfo
}
leveler := &slog.LevelVar{}
leveler.Set(programLevel)
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: leveler,
})
slog.SetDefault(slog.New(h))
}
func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
host := r.Host
if host == "" {
@@ -23,3 +44,27 @@ func GetRequestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
"x-real-ip", r.Header.Get("X-Real-Ip"),
)
}
// ErrorLogFilter is used to suppress "context canceled" logs from the http server when a request is canceled (e.g., when a client disconnects).
type ErrorLogFilter struct {
Unwrap *log.Logger
}
func (elf *ErrorLogFilter) Write(p []byte) (n int, err error) {
logMessage := string(p)
if strings.Contains(logMessage, "context canceled") {
return len(p), nil // Suppress the log by doing nothing
}
if strings.Contains(logMessage, "Unsolicited response received on idle HTTP channel") {
return len(p), nil
}
if elf.Unwrap != nil {
return elf.Unwrap.Writer().Write(p)
}
return len(p), nil
}
func GetFilteredHTTPLogger() *log.Logger {
stdErrLogger := log.New(os.Stderr, "", log.LstdFlags) // essentially what the default logger is.
return log.New(&ErrorLogFilter{Unwrap: stdErrLogger}, "", 0)
}

82
internal/log_test.go Normal file
View File

@@ -0,0 +1,82 @@
package internal
import (
"bytes"
"log"
"log/slog"
"net/http"
"strings"
"testing"
)
func TestErrorLogFilter(t *testing.T) {
var buf bytes.Buffer
destLogger := log.New(&buf, "", 0)
errorFilterWriter := &ErrorLogFilter{Unwrap: destLogger}
testErrorLogger := log.New(errorFilterWriter, "", 0)
// Test Case 1: Suppressed message
suppressedMessage := "http: proxy error: context canceled"
testErrorLogger.Println(suppressedMessage)
if buf.Len() != 0 {
t.Errorf("Suppressed message was written to output. Output: %q", buf.String())
}
buf.Reset()
// Test Case 2: Allowed message
allowedMessage := "http: another error occurred"
testErrorLogger.Println(allowedMessage)
output := buf.String()
if !strings.Contains(output, allowedMessage) {
t.Errorf("Allowed message was not written to output. Output: %q", output)
}
if !strings.HasSuffix(output, "\n") {
t.Errorf("Allowed message output is missing newline. Output: %q", output)
}
buf.Reset()
// Test Case 3: Partially matching message (should be suppressed)
partiallyMatchingMessage := "Some other log before http: proxy error: context canceled and after"
testErrorLogger.Println(partiallyMatchingMessage)
if buf.Len() != 0 {
t.Errorf("Partially matching message was written to output. Output: %q", buf.String())
}
buf.Reset()
}
func TestGetRequestLogger(t *testing.T) {
// Test case 1: Normal request with Host header
req1, _ := http.NewRequest("GET", "http://example.com/test", nil)
req1.Host = "example.com"
logger := slog.Default()
reqLogger := GetRequestLogger(logger, req1)
// We can't easily test the actual log output without setting up a test handler,
// but we can verify the function doesn't panic and returns a logger
if reqLogger == nil {
t.Error("GetRequestLogger returned nil")
}
// Test case 2: Subrequest auth mode with X-Forwarded-Host
req2, _ := http.NewRequest("GET", "http://test.com/auth", nil)
req2.Host = ""
req2.Header.Set("X-Forwarded-Host", "original-site.com")
reqLogger2 := GetRequestLogger(logger, req2)
if reqLogger2 == nil {
t.Error("GetRequestLogger returned nil for X-Forwarded-Host case")
}
// Test case 3: No host information available
req3, _ := http.NewRequest("GET", "http://test.com/nohost", nil)
req3.Host = ""
reqLogger3 := GetRequestLogger(logger, req3)
if reqLogger3 == nil {
t.Error("GetRequestLogger returned nil for no host case")
}
}

View File

@@ -78,10 +78,6 @@ type Server struct {
logger *slog.Logger
}
func (s *Server) GetLogger(subsystem string) *slog.Logger {
return s.logger.With("subsystem", subsystem)
}
func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
// return ED25519 key if HS512 is not set
if len(s.hs512Secret) == 0 {

View File

@@ -299,6 +299,7 @@ func TestCookieSettings(t *testing.T) {
CookieDomain: "127.0.0.1",
CookiePartitioned: true,
CookieSecure: true,
CookieSameSite: http.SameSiteNoneMode,
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
@@ -339,6 +340,65 @@ func TestCookieSettings(t *testing.T) {
if ckie.Secure != srv.opts.CookieSecure {
t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
}
if ckie.SameSite != srv.opts.CookieSameSite {
t.Errorf("wanted same site option %v, got: %v", srv.opts.CookieSameSite, ckie.SameSite)
}
}
func TestCookieSettingsSameSiteNoneModeDowngradedToLaxWhenUnsecure(t *testing.T) {
pol := loadPolicies(t, "testdata/zero_difficulty.yaml", 0)
srv := spawnAnubis(t, Options{
Next: http.NewServeMux(),
Policy: pol,
CookieDomain: "127.0.0.1",
CookiePartitioned: true,
CookieSecure: false,
CookieSameSite: http.SameSiteNoneMode,
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
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 {
resp.Write(os.Stderr)
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
}
var ckie *http.Cookie
for _, cookie := range resp.Cookies() {
t.Logf("%#v", cookie)
if cookie.Name == anubis.CookieName {
ckie = cookie
break
}
}
if ckie == nil {
t.Errorf("Cookie %q not found", anubis.CookieName)
return
}
if ckie.Domain != "127.0.0.1" {
t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain)
}
if ckie.Partitioned != srv.opts.CookiePartitioned {
t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
}
if ckie.Secure != srv.opts.CookieSecure {
t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
}
if ckie.SameSite != http.SameSiteLaxMode {
t.Errorf("wanted same site Lax option %v, got: %v", http.SameSiteLaxMode, ckie.SameSite)
}
}
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {

View File

@@ -43,6 +43,7 @@ type Options struct {
OpenGraph config.OpenGraph
ServeRobotsTXT bool
CookieSecure bool
CookieSameSite http.SameSite
Logger *slog.Logger
PublicUrl string
JWTRestrictionHeader string
@@ -120,14 +121,6 @@ func New(opts Options) (*Server, error) {
logger: opts.Logger,
}
if opts.Policy.Logging != nil {
var err error
result.logger, err = opts.Policy.ApplyLogFilters(opts.Logger)
if err != nil {
return nil, fmt.Errorf("can't create log filters: %w", err)
}
}
mux := http.NewServeMux()
xess.Mount(mux)

View File

@@ -56,6 +56,8 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
var domain = s.opts.CookieDomain
var name = anubis.CookieName
var path = "/"
var sameSite = s.opts.CookieSameSite
if cookieOpts.Name != "" {
name = cookieOpts.Name
}
@@ -72,11 +74,15 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
cookieOpts.Expiry = s.opts.CookieExpiration
}
if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
sameSite = http.SameSiteLaxMode
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: cookieOpts.Value,
Expires: time.Now().Add(cookieOpts.Expiry),
SameSite: http.SameSiteNoneMode,
SameSite: sameSite,
Domain: domain,
Secure: s.opts.CookieSecure,
Partitioned: s.opts.CookiePartitioned,
@@ -88,6 +94,8 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
var domain = s.opts.CookieDomain
var name = anubis.CookieName
var path = "/"
var sameSite = s.opts.CookieSameSite
if cookieOpts.Name != "" {
name = cookieOpts.Name
}
@@ -99,13 +107,16 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
domain = etld
}
}
if s.opts.CookieSameSite == http.SameSiteNoneMode && !s.opts.CookieSecure {
sameSite = http.SameSiteLaxMode
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
MaxAge: -1,
Expires: time.Now().Add(-1 * time.Minute),
SameSite: http.SameSiteNoneMode,
SameSite: sameSite,
Partitioned: s.opts.CookiePartitioned,
Domain: domain,
Secure: s.opts.CookieSecure,

View File

@@ -1,67 +0,0 @@
package logging
import (
"context"
"log/slog"
)
// Filterer is the shape of any type that can perform log filtering. This takes
// the context of the log filtering call and the log record to be filtered.
type Filterer interface {
Filter(ctx context.Context, r slog.Record) bool
}
// FilterFunc lets you make inline log filters with plain functions.
type FilterFunc func(ctx context.Context, r *slog.Record) bool
// Filter implements Filterer for FilterFunc.
func (ff FilterFunc) Filter(ctx context.Context, r *slog.Record) bool {
return ff(ctx, r)
}
// FilterHandler wraps a slog Handler with one or more filters, enabling administrators
// to customize the logging subsystem of Anubis.
type FilterHandler struct {
next slog.Handler
filters []Filterer
}
// NewFilterHandler creates a new filtering handler with the given base handler and filters.
func NewFilterHandler(handler slog.Handler, filters ...Filterer) *FilterHandler {
return &FilterHandler{
next: handler,
filters: filters,
}
}
// Enabled passes through to the upstream slog Handler.
func (h *FilterHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
// Handle implements slog.Handler and applies all filters before delegating to the base handler.
func (h *FilterHandler) Handle(ctx context.Context, r slog.Record) error {
// Apply all filters - if any filter returns false, skip the log
for _, filter := range h.filters {
if !filter.Filter(ctx, r) {
return nil // Skip this log record
}
}
return h.next.Handle(ctx, r)
}
// WithAttrs implements slog.Handler.
func (h *FilterHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &FilterHandler{
next: h.next.WithAttrs(attrs),
filters: h.filters,
}
}
// WithGroup implements slog.Handler.
func (h *FilterHandler) WithGroup(name string) slog.Handler {
return &FilterHandler{
next: h.next.WithGroup(name),
filters: h.filters,
}
}

View File

@@ -1,24 +0,0 @@
package logging
import (
"fmt"
"log/slog"
"os"
)
func Init(level string) slog.Handler {
var programLevel slog.Level
if err := (&programLevel).UnmarshalText([]byte(level)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err)
programLevel = slog.LevelInfo
}
leveler := &slog.LevelVar{}
leveler.Set(programLevel)
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: leveler,
})
return h
}

View File

@@ -1,36 +0,0 @@
package logging
import (
"bytes"
"context"
"log"
"log/slog"
"time"
)
// handlerWriter is an io.Writer that calls a Handler.
// It is used to link the default log.Logger to the default slog.Logger.
//
// Adapted from https://cs.opensource.google/go/go/+/refs/tags/go1.24.5:src/log/slog/logger.go;l=62
type handlerWriter struct {
h slog.Handler
level slog.Leveler
}
func (w *handlerWriter) Write(buf []byte) (int, error) {
level := w.level.Level()
if !w.h.Enabled(context.Background(), level) {
return 0, nil
}
var pc uintptr
// Remove final newline.
origLen := len(buf) // Report that the entire buf was written.
buf = bytes.TrimSuffix(buf, []byte{'\n'})
r := slog.NewRecord(time.Now(), level, string(buf), pc)
return origLen, w.h.Handle(context.Background(), r)
}
func StdlibLogger(next slog.Handler, level slog.Level) *log.Logger {
return log.New(&handlerWriter{h: next, level: level}, "", log.LstdFlags)
}

View File

@@ -61,6 +61,8 @@ func (cr *CELRequest) ResolveName(name string) (any, bool) {
switch name {
case "remoteAddress":
return cr.Header.Get("X-Real-Ip"), true
case "contentLength":
return cr.ContentLength, true
case "host":
return cr.Host, true
case "method":

View File

@@ -326,7 +326,6 @@ func (sc StatusCodes) Valid() error {
type fileConfig struct {
Bots []BotOrImport `json:"bots"`
DNSBL bool `json:"dnsbl"`
Logging *Logging `json:"logging"`
OpenGraph openGraphFileConfig `json:"openGraph,omitempty"`
Impressum *Impressum `json:"impressum,omitempty"`
StatusCodes StatusCodes `json:"status_codes"`
@@ -369,12 +368,6 @@ func (c *fileConfig) Valid() error {
}
}
if c.Logging != nil {
if err := c.Logging.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) != 0 {
return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
}
@@ -408,7 +401,6 @@ func Load(fin io.Reader, fname string) (*Config, error) {
ConsiderHost: c.OpenGraph.ConsiderHost,
Override: c.OpenGraph.Override,
},
Logging: c.Logging,
StatusCodes: c.StatusCodes,
Store: c.Store,
}
@@ -449,12 +441,6 @@ func Load(fin io.Reader, fname string) (*Config, error) {
result.Impressum = c.Impressum
}
if c.Logging != nil {
if err := c.Logging.Valid(); err != nil {
validationErrs = append(validationErrs, err)
}
}
if len(c.Thresholds) == 0 {
c.Thresholds = DefaultThresholds
}
@@ -479,7 +465,6 @@ type Config struct {
Bots []BotConfig
Thresholds []Threshold
DNSBL bool
Logging *Logging
Impressum *Impressum
OpenGraph OpenGraph
StatusCodes StatusCodes

View File

@@ -1,49 +0,0 @@
package config
import (
"errors"
"fmt"
)
type Logging struct {
Filters []LogFilter `json:"filters,omitempty" yaml:"filters,omitempty"`
}
func (l *Logging) Valid() error {
var errs []error
for _, lf := range l.Filters {
if err := lf.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) != 0 {
return errors.Join(errs...)
}
return nil
}
type LogFilter struct {
Name string `json:"name" yaml:"name"`
Expression ExpressionOrList `json:"expression" yaml:"expression"`
}
func (lf LogFilter) Valid() error {
var errs []error
if lf.Name == "" {
errs = append(errs, fmt.Errorf("%w: log filter has no name", ErrMissingValue))
}
if err := lf.Expression.Valid(); err != nil {
errs = append(errs, err)
}
if len(errs) != 0 {
return fmt.Errorf("log filter %q is not valid: %w", lf.Name, errors.Join(errs...))
}
return nil
}

View File

@@ -19,6 +19,7 @@ func BotEnvironment() (*cel.Env, error) {
return New(
// Variables exposed to CEL programs:
cel.Variable("remoteAddress", cel.StringType),
cel.Variable("contentLength", cel.IntType),
cel.Variable("host", cel.StringType),
cel.Variable("method", cel.StringType),
cel.Variable("userAgent", cel.StringType),

View File

@@ -1,114 +0,0 @@
package expressions
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
timestamp "google.golang.org/protobuf/types/known/timestamppb"
)
var (
filterExecutionTime = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "anubis",
Subsystem: "slog",
Name: "filter_execution_time_nanoseconds",
Help: "How long each log filter took to execute (nanoseconds)",
Buckets: []float64{10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000, 1000000, 2000000, 5000000, 10000000}, // 10 nanoseconds to 10 milliseconds
}, []string{"name"})
)
func LogFilter(opts ...cel.EnvOption) (*cel.Env, error) {
return New(
// Slog record metadata
cel.Variable("time", cel.TimestampType),
cel.Variable("msg", cel.StringType),
cel.Variable("level", cel.StringType),
cel.Variable("attrs", cel.MapType(cel.StringType, cel.StringType)),
)
}
func NewFilter(lg *slog.Logger, name, src string) (*Filter, error) {
env, err := LogFilter()
if err != nil {
return nil, fmt.Errorf("logging: can't create CEL env: %w", err)
}
program, err := Compile(env, src)
if err != nil {
return nil, fmt.Errorf("logging: can't compile expression: Compile(%q): %w", src, err)
}
return &Filter{
program: program,
name: name,
src: src,
log: lg.With("filter", name),
}, nil
}
type Filter struct {
program cel.Program
name string
src string
log *slog.Logger
}
func (f Filter) Filter(ctx context.Context, r slog.Record) bool {
t0 := time.Now()
result, _, err := f.program.ContextEval(ctx, &Record{
Record: r,
})
if err != nil {
f.log.Error("error executing log filter", "err", err, "src", f.src)
return false
}
dur := time.Since(t0)
filterExecutionTime.WithLabelValues(f.name).Observe(float64(dur.Nanoseconds()))
//f.log.Debug("filter execution", "dur", dur.Nanoseconds())
if val, ok := result.(types.Bool); ok {
return !bool(val)
}
return false
}
type Record struct {
slog.Record
attrs map[string]string
}
func (r *Record) Parent() cel.Activation { return nil }
func (r *Record) ResolveName(name string) (any, bool) {
switch name {
case "time":
return &timestamp.Timestamp{Seconds: r.Time.Unix()}, true
case "msg":
return r.Message, true
case "level":
return r.Level.String(), true
case "attrs":
if r.attrs == nil {
attrs := map[string]string{}
r.Attrs(func(attr slog.Attr) bool {
attrs[attr.Key] = attr.Value.String()
return true
})
r.attrs = attrs
return attrs, true
}
return r.attrs, true
default:
return nil, false
}
}

View File

@@ -1,65 +0,0 @@
package expressions
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"testing"
"time"
)
func BenchmarkFilter(b *testing.B) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
filter, err := NewFilter(log, "benchmark", `msg == "hello"`)
if err != nil {
b.Fatalf("NewFilter() error = %v", err)
}
record := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0)
record.AddAttrs(slog.String("foo", "bar"))
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
filter.Filter(ctx, record)
}
}
func BenchmarkFilterAttributes(b *testing.B) {
for _, numAttrs := range []int{1, 2, 4, 8, 16, 32} {
b.Run(fmt.Sprintf("%d_attributes", numAttrs), func(b *testing.B) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
var sb strings.Builder
sb.WriteString(`msg == "hello" && "foo" in attrs`)
attrs := make([]slog.Attr, numAttrs)
for i := range numAttrs {
key := fmt.Sprintf("foo%d", i)
val := "bar"
attrs[i] = slog.String(key, val)
}
filter, err := NewFilter(log, "benchmark", sb.String())
if err != nil {
b.Fatalf("NewFilter() error = %v", err)
}
record := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0)
record.AddAttrs(attrs...)
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for b.Loop() {
filter.Filter(ctx, record)
}
})
}
}

View File

@@ -8,10 +8,8 @@ import (
"log/slog"
"sync/atomic"
"github.com/TecharoHQ/anubis/lib/logging"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/policy/expressions"
"github.com/TecharoHQ/anubis/lib/store"
"github.com/TecharoHQ/anubis/lib/thoth"
"github.com/prometheus/client_golang/prometheus"
@@ -37,42 +35,17 @@ type ParsedConfig struct {
Thresholds []*Threshold
DNSBL bool
Impressum *config.Impressum
Logging *config.Logging
OpenGraph config.OpenGraph
DefaultDifficulty int
StatusCodes config.StatusCodes
Store store.Interface
}
func (pc *ParsedConfig) ApplyLogFilters(base *slog.Logger) (*slog.Logger, error) {
var errs []error
var filters []logging.Filterer
for _, f := range pc.Logging.Filters {
filter, err := expressions.NewFilter(base, f.Name, f.Expression.String())
if err != nil {
errs = append(errs, fmt.Errorf("filter %s invalid: %w", f.Name, err))
continue
}
filters = append(filters, filter)
}
result := slog.New(logging.NewFilterHandler(base.Handler(), filters...))
slog.SetDefault(result)
if len(errs) != 0 {
return nil, errors.Join(errs...)
}
return result, nil
}
func newParsedConfig(orig *config.Config) *ParsedConfig {
return &ParsedConfig{
orig: orig,
OpenGraph: orig.OpenGraph,
StatusCodes: orig.StatusCodes,
Logging: orig.Logging,
}
}

View File

@@ -0,0 +1,82 @@
package store
import (
"context"
"time"
"github.com/TecharoHQ/anubis/internal/actorify"
)
type unit struct{}
type ActorifiedStore struct {
Interface
deleteActor *actorify.Actor[string, unit]
getActor *actorify.Actor[string, []byte]
setActor *actorify.Actor[*actorSetReq, unit]
cancel context.CancelFunc
}
type actorSetReq struct {
key string
value []byte
expiry time.Duration
}
func NewActorifiedStore(backend Interface) *ActorifiedStore {
ctx, cancel := context.WithCancel(context.Background())
result := &ActorifiedStore{
Interface: backend,
cancel: cancel,
}
result.deleteActor = actorify.New(ctx, result.actorDelete)
result.getActor = actorify.New(ctx, backend.Get)
result.setActor = actorify.New(ctx, result.actorSet)
return result
}
func (a *ActorifiedStore) Close() { a.cancel() }
func (a *ActorifiedStore) Delete(ctx context.Context, key string) error {
if _, err := a.deleteActor.Call(ctx, key); err != nil {
return err
}
return nil
}
func (a *ActorifiedStore) Get(ctx context.Context, key string) ([]byte, error) {
return a.getActor.Call(ctx, key)
}
func (a *ActorifiedStore) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
if _, err := a.setActor.Call(ctx, &actorSetReq{
key: key,
value: value,
expiry: expiry,
}); err != nil {
return err
}
return nil
}
func (a *ActorifiedStore) actorDelete(ctx context.Context, key string) (unit, error) {
if err := a.Interface.Delete(ctx, key); err != nil {
return unit{}, err
}
return unit{}, nil
}
func (a *ActorifiedStore) actorSet(ctx context.Context, req *actorSetReq) (unit, error) {
if err := a.Interface.Set(ctx, req.key, req.value, req.expiry); err != nil {
return unit{}, err
}
return unit{}, nil
}

View File

@@ -48,7 +48,7 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
go result.cleanupThread(ctx)
return result, nil
return store.NewActorifiedStore(result), nil
}
// Valid parses and validates the bbolt store Config or returns

View File

@@ -4,30 +4,37 @@ set -euo pipefail
[ ! -z "${DEBUG:-}" ] && set -x
if [ "$#" -ne 1 ]; then
echo "Usage: rigging.sh <user@host>"
echo "Usage: rigging.sh <user@host>"
fi
declare -A Hosts
Hosts["riscv64"]="ubuntu@riscv64.techaro.lol" # GOARCH=riscv64 GOOS=linux
Hosts["ppc64le"]="ci@ppc64le.techaro.lol" # GOARCH=ppc64le GOOS=linux
Hosts["aarch64-4k"]="rocky@192.168.2.52" # GOARCH=arm64 GOOS=linux 4k page size
Hosts["aarch64-16k"]="ci@192.168.2.28" # GOARCH=arm64 GOOS=linux 16k page size
CIRunnerImage="ghcr.io/techarohq/anubis/ci-runner:latest"
RunID=${GITHUB_RUN_ID:-$(uuidgen)}
RunFolder="anubis/runs/${RunID}"
Target="${1}"
Target="${Hosts["$1"]}"
ssh "${Target}" uname -av
ssh "${Target}" uname -av >/dev/null
ssh "${Target}" mkdir -p "${RunFolder}"
git archive HEAD | ssh "${Target}" tar xC "${RunFolder}"
ssh "${Target}" << EOF
ssh "${Target}" <<EOF
set -euo pipefail
set -x
mkdir -p "anubis/cache/{go,go-build,node}"
mkdir -p anubis/cache/{go,go-build,node}
podman pull ${CIRunnerImage}
podman run --rm -it \
-v "\$HOME/${RunFolder}:/app/anubis" \
-v "\$HOME/anubis/cache/go:/root/go" \
-v "\$HOME/anubis/cache/go-build:/root/.cache/go-build" \
-v "\$HOME/anubis/cache/node:/root/.npm" \
-v "\$HOME/${RunFolder}:/app/anubis:z" \
-v "\$HOME/anubis/cache/go:/root/go:z" \
-v "\$HOME/anubis/cache/go-build:/root/.cache/go-build:z" \
-v "\$HOME/anubis/cache/node:/root/.npm:z" \
-w /app/anubis \
${CIRunnerImage} \
sh /app/anubis/test/ssh-ci/in-container.sh
ssh "${Target}" rm -rf "${RunFolder}"
EOF
EOF