Compare commits

..

9 Commits

Author SHA1 Message Date
Xe Iaso
b12c5945b3 test(caddy): why are you failing?
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-19 04:51:12 +00:00
Xe Iaso
b320af79ee test(caddy): fix automated testing?
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-19 04:48:43 +00:00
Xe Iaso
cf510eeace chore: spelling
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-19 00:46:23 -04:00
Xe Iaso
a47e781d91 test(caddy): fix automated testing
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-19 04:44:34 +00:00
Xe Iaso
06122fcd89 chore(devcontainer): put node_modules in a volume
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-18 22:07:22 -04:00
Xe Iaso
950ec24cf1 ci: enable caddy and unix-socket-xff tests
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-18 22:07:06 -04:00
Xe Iaso
7cfefcd882 test: make unix-socket-xff into a smoke test
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-18 22:06:00 -04:00
Xe Iaso
eacc001d69 fix(cmd/anubis): ignore errors for unix socket chmod
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-18 22:03:07 -04:00
Xe Iaso
7ca45f72e3 test: adapt caddy testing environment into a smoke test
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-18 22:02:34 -04:00
20 changed files with 170 additions and 429 deletions

View File

@@ -21,7 +21,8 @@
"golang.go",
"unifiedjs.vscode-mdx",
"a-h.templ",
"redhat.vscode-yaml"
"redhat.vscode-yaml",
"matthewpi.caddyfile-support"
]
}
}

View File

@@ -20,7 +20,11 @@ services:
dockerfile: .devcontainer/Dockerfile
volumes:
- ../:/workspace/anubis:cached
- node_modules:/workspace/anubis/node_modules
environment:
VALKEY_URL: redis://valkey:6379/0
#entrypoint: ["/usr/bin/sleep", "infinity"]
user: vscode
volumes:
node_modules:

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env bash
pwd
sudo chown -R vscode:vscode ./node_modules
npm ci &
go mod download &
go install ./utils/cmd/... &
go install mvdan.cc/sh/v3/cmd/shfmt@latest &
wait

View File

@@ -32,7 +32,7 @@ byteslice
Bytespider
cachebuster
cachediptoasn
Caddyfile
caddyfile
caninetools
Cardyb
celchecker
@@ -181,6 +181,7 @@ lol
lominsa
maintainership
malware
matthewpi
mcr
memes
metarefresh

View File

@@ -14,10 +14,12 @@ jobs:
strategy:
matrix:
test:
- caddy
- git-clone
- git-push
- healthcheck
- i18n
- unix-socket-xff
runs-on: ubuntu-24.04
steps:
- name: Checkout code

View File

@@ -5,6 +5,7 @@
"golang.go",
"unifiedjs.vscode-mdx",
"a-h.templ",
"redhat.vscode-yaml"
"redhat.vscode-yaml",
"matthewpi.caddyfile-support"
]
}

View File

@@ -166,19 +166,19 @@ func setupListener(network string, address string) (net.Listener, string) {
// additional permission handling for unix sockets
if network == "unix" {
slog.Debug("parsing socket mode", "mode_string", *socketMode, "address", address)
mode, err := strconv.ParseUint(*socketMode, 8, 0)
if err != nil {
listener.Close()
log.Fatal(fmt.Errorf("could not parse socket mode %s: %w", *socketMode, err))
slog.Error("could not parse socket mode", "mode", *socketMode, "err", err)
os.Exit(1)
}
err = os.Chmod(address, os.FileMode(mode))
if err != nil {
err := listener.Close()
if err != nil {
log.Printf("failed to close listener: %v", err)
}
log.Fatal(fmt.Errorf("could not change socket mode: %w", err))
if err := os.Chmod(address, os.FileMode(mode)); err != nil {
// Ignore chmod errors on Unix domain sockets - this is expected behavior
// on many systems/containers where socket permissions cannot be changed
slog.Debug("chmod failed on socket (ignoring)", "path", address, "err", err)
}
}

View File

@@ -12,7 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
<!-- This changes the project to: -->
- Expired records are now properly removed from bbolt databases ([#848](https://github.com/TecharoHQ/anubis/pull/848)).
- Fix hanging on service restart ([#853](https://github.com/TecharoHQ/anubis/issues/853))
@@ -23,8 +22,6 @@ Anubis now supports these new languages:
- [Czech](https://github.com/TecharoHQ/anubis/pull/849)
Anubis now supports the [`missingHeader`](./admin/configuration/expressions.mdx#missingHeader) to assert the absence of headers in requests.
## v1.21.0: Minfilia Warde
> Please, be at ease. You are among friends here.

View File

@@ -77,7 +77,7 @@ For example, consider this rule:
For this rule, if a request comes in from `8.8.8.8` or `1.1.1.1`, Anubis will deny the request and return an error page.
### `all` blocks
#### `all` blocks
An `all` block that contains a list of expressions. If all expressions in the list return `true`, then the action specified in the rule will be taken. If any of the expressions in the list returns `false`, Anubis will move on to the next rule.
@@ -186,32 +186,8 @@ Also keep in mind that this does not account for other kinds of latency like I/O
Anubis expressions can be augmented with the following functions:
### `missingHeader`
Available in `bot` expressions.
```ts
function missingHeader(headers: Record<string, string>, key: string) bool
```
`missingHeader` returns `true` if the request does not contain a header. This is useful when you are trying to assert behavior such as:
```yaml
# Adds weight to old versions of Chrome
- name: old-chrome
action: WEIGH
weight:
adjust: 10
expression:
all:
- userAgent.matches("Chrome/[1-9][0-9]?\\.0\\.0\\.0")
- missingHeader(headers, "Sec-Ch-Ua")
```
### `randInt`
Available in all expressions.
```ts
function randInt(n: int): int;
```

View File

@@ -6,7 +6,6 @@ import (
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
"github.com/google/cel-go/ext"
)
@@ -27,33 +26,6 @@ func BotEnvironment() (*cel.Env, error) {
cel.Variable("load_1m", cel.DoubleType),
cel.Variable("load_5m", cel.DoubleType),
cel.Variable("load_15m", cel.DoubleType),
// Bot-specific functions:
cel.Function("missingHeader",
cel.Overload("missingHeader_map_string_string_string",
[]*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.StringType},
cel.BoolType,
cel.BinaryBinding(func(headers, key ref.Val) ref.Val {
// Convert headers to a trait that supports Find
headersMap, ok := headers.(traits.Indexer)
if !ok {
return types.ValOrErr(headers, "headers is not a map, but is %T", headers)
}
keyStr, ok := key.(types.String)
if !ok {
return types.ValOrErr(key, "key is not a string, but is %T", key)
}
val := headersMap.Get(keyStr)
// Check if the key is missing by testing for an error
if types.IsError(val) {
return types.Bool(true) // header is missing
}
return types.Bool(false) // header is present
}),
),
),
)
}

View File

@@ -1,269 +0,0 @@
package expressions
import (
"testing"
"github.com/google/cel-go/common/types"
)
func TestBotEnvironment(t *testing.T) {
env, err := BotEnvironment()
if err != nil {
t.Fatalf("failed to create bot environment: %v", err)
}
tests := []struct {
name string
expression string
headers map[string]string
expected types.Bool
description string
}{
{
name: "missing-header",
expression: `missingHeader(headers, "Missing-Header")`,
headers: map[string]string{
"User-Agent": "test-agent",
"Content-Type": "application/json",
},
expected: types.Bool(true),
description: "should return true when header is missing",
},
{
name: "existing-header",
expression: `missingHeader(headers, "User-Agent")`,
headers: map[string]string{
"User-Agent": "test-agent",
"Content-Type": "application/json",
},
expected: types.Bool(false),
description: "should return false when header exists",
},
{
name: "case-sensitive",
expression: `missingHeader(headers, "user-agent")`,
headers: map[string]string{
"User-Agent": "test-agent",
},
expected: types.Bool(true),
description: "should be case-sensitive (user-agent != User-Agent)",
},
{
name: "empty-headers",
expression: `missingHeader(headers, "Any-Header")`,
headers: map[string]string{},
expected: types.Bool(true),
description: "should return true for any header when map is empty",
},
{
name: "real-world-sec-ch-ua",
expression: `missingHeader(headers, "Sec-Ch-Ua")`,
headers: map[string]string{
"User-Agent": "curl/7.68.0",
"Accept": "*/*",
"Host": "example.com",
},
expected: types.Bool(true),
description: "should detect missing browser-specific headers from bots",
},
{
name: "browser-with-sec-ch-ua",
expression: `missingHeader(headers, "Sec-Ch-Ua")`,
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Sec-Ch-Ua": `"Chrome"; v="91", "Not A Brand"; v="99"`,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
expected: types.Bool(false),
description: "should return false when browser sends Sec-Ch-Ua header",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
result, _, err := prog.Eval(map[string]interface{}{
"headers": tt.headers,
})
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result != tt.expected {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
}
t.Run("function-compilation", func(t *testing.T) {
src := `missingHeader(headers, "Test-Header")`
_, err := Compile(env, src)
if err != nil {
t.Fatalf("failed to compile missingHeader expression: %v", err)
}
})
}
func TestThresholdEnvironment(t *testing.T) {
env, err := ThresholdEnvironment()
if err != nil {
t.Fatalf("failed to create threshold environment: %v", err)
}
tests := []struct {
name string
expression string
variables map[string]interface{}
expected types.Bool
description string
shouldCompile bool
}{
{
name: "weight-variable-available",
expression: `weight > 100`,
variables: map[string]interface{}{"weight": 150},
expected: types.Bool(true),
description: "should support weight variable in expressions",
shouldCompile: true,
},
{
name: "weight-variable-false-case",
expression: `weight > 100`,
variables: map[string]interface{}{"weight": 50},
expected: types.Bool(false),
description: "should correctly evaluate weight comparisons",
shouldCompile: true,
},
{
name: "missingHeader-not-available",
expression: `missingHeader(headers, "Test")`,
variables: map[string]interface{}{},
expected: types.Bool(false), // not used
description: "should not have missingHeader function available",
shouldCompile: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if !tt.shouldCompile {
if err == nil {
t.Fatalf("%s: expected compilation to fail but it succeeded", tt.description)
}
return // Test passed - compilation failed as expected
}
if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
result, _, err := prog.Eval(tt.variables)
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result != tt.expected {
t.Errorf("%s: expected %v, got %v", tt.description, tt.expected, result)
}
})
}
}
func TestNewEnvironment(t *testing.T) {
env, err := New()
if err != nil {
t.Fatalf("failed to create new environment: %v", err)
}
tests := []struct {
name string
expression string
variables map[string]interface{}
expectBool *bool // nil if we just want to test compilation or non-bool result
description string
shouldCompile bool
}{
{
name: "randInt-function-compilation",
expression: `randInt(10)`,
variables: map[string]interface{}{},
expectBool: nil, // Don't check result, just compilation
description: "should compile randInt function",
shouldCompile: true,
},
{
name: "randInt-range-validation",
expression: `randInt(10) >= 0 && randInt(10) < 10`,
variables: map[string]interface{}{},
expectBool: boolPtr(true),
description: "should return values in correct range",
shouldCompile: true,
},
{
name: "strings-extension-size",
expression: `"hello".size() == 5`,
variables: map[string]interface{}{},
expectBool: boolPtr(true),
description: "should support string extension functions",
shouldCompile: true,
},
{
name: "strings-extension-contains",
expression: `"hello world".contains("world")`,
variables: map[string]interface{}{},
expectBool: boolPtr(true),
description: "should support string contains function",
shouldCompile: true,
},
{
name: "strings-extension-startsWith",
expression: `"hello world".startsWith("hello")`,
variables: map[string]interface{}{},
expectBool: boolPtr(true),
description: "should support string startsWith function",
shouldCompile: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prog, err := Compile(env, tt.expression)
if !tt.shouldCompile {
if err == nil {
t.Fatalf("%s: expected compilation to fail but it succeeded", tt.description)
}
return // Test passed - compilation failed as expected
}
if err != nil {
t.Fatalf("failed to compile expression %q: %v", tt.expression, err)
}
// If we only want to test compilation, skip evaluation
if tt.expectBool == nil {
return
}
result, _, err := prog.Eval(tt.variables)
if err != nil {
t.Fatalf("failed to evaluate expression %q: %v", tt.expression, err)
}
if result != types.Bool(*tt.expectBool) {
t.Errorf("%s: expected %v, got %v", tt.description, *tt.expectBool, result)
}
})
}
}
// Helper function to create bool pointers
func boolPtr(b bool) *bool {
return &b
}

View File

@@ -0,0 +1,10 @@
bots:
- name: challenge
user_agent_regex: Mozilla
action: WEIGH
weight:
adjust: 10
status_codes:
CHALLENGE: 401
DENY: 403

View File

@@ -1,12 +1,5 @@
:80 {
reverse_proxy http://anubis:3000 {
header_up X-Real-Ip {remote_host}
header_up X-Http-Version {http.request.proto}
}
}
:443 {
tls /etc/techaro/pki/caddy.local.cetacean.club/cert.pem /etc/techaro/pki/caddy.local.cetacean.club/key.pem
caddy.local.cetacean.club {
tls internal
reverse_proxy http://anubis:3000 {
header_up X-Real-Ip {remote_host}

View File

@@ -5,18 +5,16 @@ services:
ports:
- 8080:80
- 8443:443
volumes:
- "../pki/caddy.local.cetacean.club:/etc/techaro/pki/caddy.local.cetacean.club/"
anubis:
image: ghcr.io/techarohq/anubis:main
image: ghcr.io/techarohq/anubis
environment:
BIND: ":3000"
TARGET: http://httpdebug:3000
POLICY_FNAME: /etc/techaro/anubis/less_paranoid.yaml
POLICY_FNAME: /cfg/less_paranoid.yaml
SLOG_LEVEL: DEBUG
volumes:
- ../anubis_configs:/etc/techaro/anubis
- ../anubis_configs:/cfg
httpdebug:
image: ghcr.io/xe/x/httpdebug
pull_policy: always

View File

@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# If the transient local TLS certificate doesn't exist, mint a new one
if [ ! -f ../pki/caddy.local.cetacean.club/cert.pem ]; then
# Subshell to contain the directory change
(
cd ../pki \
&& mkdir -p caddy.local.cetacean.club \
&& \
# Try using https://github.com/FiloSottile/mkcert for better DevEx,
# but fall back to using https://github.com/jsha/minica in case
# you don't have that installed.
(
mkcert \
--cert-file ./caddy.local.cetacean.club/cert.pem \
--key-file ./caddy.local.cetacean.club/key.pem caddy.local.cetacean.club \
|| go tool minica -domains caddy.local.cetacean.club
)
)
fi
docker compose up --build

27
test/caddy/test.mjs Normal file
View File

@@ -0,0 +1,27 @@
async function testWithUserAgent(userAgent) {
const statusCode =
await fetch("https://caddy.local.cetacean.club:8443/reqmeta", {
headers: {
"User-Agent": userAgent,
}
})
.then(resp => resp.status);
return statusCode;
}
const codes = {
Mozilla: await testWithUserAgent("Mozilla"),
curl: await testWithUserAgent("curl"),
}
const expected = {
Mozilla: 401,
curl: 200,
};
console.log("Mozilla:", codes.Mozilla);
console.log("curl: ", codes.curl);
if (JSON.stringify(codes) !== JSON.stringify(expected)) {
throw new Error(`wanted ${JSON.stringify(expected)}, got: ${JSON.stringify(codes)}`);
}

17
test/caddy/test.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -x
set -euo pipefail
source ../lib/lib.sh
build_anubis_ko
docker compose up -d --build
export NODE_TLS_REJECT_UNAUTHORIZED=0
sleep 2
backoff-retry node test.mjs

54
test/lib/lib.sh Normal file
View File

@@ -0,0 +1,54 @@
REPO_ROOT=$(git rev-parse --show-toplevel)
(cd $REPO_ROOT && go install ./utils/cmd/...)
function cleanup() {
pkill -P $$
if [ -f "docker-compose.yaml" ]; then
docker compose down
fi
}
trap cleanup EXIT SIGINT
function build_anubis_ko() {
(
cd ../.. &&
VERSION=devel ko build \
--platform=all \
--base-import-paths \
--tags="latest" \
--image-user=1000 \
--image-annotation="" \
--image-label="" \
./cmd/anubis \
--local
)
}
function mint_cert() {
if [ "$#" -ne 1 ]; then
echo "Usage: mint_cert <domain.name>"
fi
domainName="$1"
# If the transient local TLS certificate doesn't exist, mint a new one
if [ ! -f "../pki/${domainName}/cert.pem" ]; then
# Subshell to contain the directory change
(
cd ../pki &&
mkdir -p "${domainName}" &&
# Try using https://github.com/FiloSottile/mkcert for better DevEx,
# but fall back to using https://github.com/jsha/minica in case
# you don't have that installed.
(
mkcert \
--cert-file ./"${domainName}"/cert.pem \
--key-file ./"${domainName}"/key.pem \
"${domainName}" ||
go tool minica -domains "${domainName}"
)
)
fi
}

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Remove lingering .sock files, relayd and unixhttpd will do that too but
# measure twice, cut once.
rm *.sock ||:
# If the transient local TLS certificate doesn't exist, mint a new one
if [ ! -f ../pki/relayd.local.cetacean.club/cert.pem ]; then
# Subshell to contain the directory change
(
cd ../pki \
&& mkdir -p relayd.local.cetacean.club \
&& \
# Try using https://github.com/FiloSottile/mkcert for better DevEx,
# but fall back to using https://github.com/jsha/minica in case
# you don't have that installed.
(
mkcert \
--cert-file ./relayd.local.cetacean.club/cert.pem \
--key-file ./relayd.local.cetacean.club/key.pem relayd.local.cetacean.club \
|| go tool minica -domains relayd.local.cetacean.club
)
)
fi
# Build static assets
(cd ../.. && npm ci && npm run assets)
# Spawn three jobs:
# HTTP daemon that listens over a unix socket (implicitly ./unixhttpd.sock)
go run ../cmd/unixhttpd &
# A copy of Anubis, specifically for the current Git checkout
go tool anubis \
--bind=./anubis.sock \
--bind-network=unix \
--policy-fname=../anubis_configs/aggressive_403.yaml \
--target=unix://$(pwd)/unixhttpd.sock &
# A simple TLS terminator that forwards to Anubis, which will forward to
# unixhttpd
go run ../cmd/relayd \
--proxy-to=unix://./anubis.sock \
--cert-dir=../pki/relayd.local.cetacean.club &
# When you press control c, kill all the child processes to clean things up
trap 'echo signal received!; kill $(jobs -p); wait' SIGINT SIGTERM
echo "open https://relayd.local.cetacean.club:3004/reqmeta"
# Wait for all child processes to exit
wait

33
test/unix-socket-xff/test.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
source ../lib/lib.sh
mint_cert "relayd.local.cetacean.club"
# Build static assets
(cd ../.. && npm ci && npm run assets)
# Spawn three jobs:
# HTTP daemon that listens over a unix socket (implicitly ./unixhttpd.sock)
go run ../cmd/unixhttpd &
# A copy of Anubis, specifically for the current Git checkout
go tool anubis \
--bind=./anubis.sock \
--bind-network=unix \
--socket-mode=0700 \
--policy-fname=../anubis_configs/aggressive_403.yaml \
--target=unix://$(pwd)/unixhttpd.sock &
# A simple TLS terminator that forwards to Anubis, which will forward to
# unixhttpd
go run ../cmd/relayd \
--proxy-to=unix://./anubis.sock \
--cert-dir=../pki/relayd.local.cetacean.club &
export NODE_TLS_REJECT_UNAUTHORIZED=0
backoff-retry node test.mjs