Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
**.pyc
.claude
artifacts/
76,086 changes: 0 additions & 76,086 deletions artifacts/e2e-full/compose.log

This file was deleted.

1 change: 0 additions & 1 deletion artifacts/e2e-full/junit.xml

This file was deleted.

7 changes: 6 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ RUN apt-get update && apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
gettext-base \
python3 \
libmaxminddb0 \
mmdb-bin \
&& rm -rf /var/lib/apt/lists/*

RUN opm get anjia0532/lua-resty-maxminddb

WORKDIR /opt/fairvisor

RUN mkdir -p /etc/fairvisor /etc/nginx/iplists
RUN mkdir -p /etc/fairvisor /etc/nginx/iplists /etc/geoip2
RUN touch /etc/geoip2/GeoLite2-Country.mmdb /etc/geoip2/GeoLite2-ASN.mmdb

COPY src /opt/fairvisor/src
COPY bin/generate_asn_type_map.py /opt/fairvisor/bin/generate_asn_type_map.py
Expand Down
8 changes: 8 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ fi
: "${FAIRVISOR_BACKEND_URL:=http://127.0.0.1:8081}"
: "${FAIRVISOR_WORKER_PROCESSES:=auto}"

# GeoIP2 databases check
if [ ! -f "/etc/geoip2/GeoLite2-Country.mmdb" ] || [ ! -f "/etc/geoip2/GeoLite2-ASN.mmdb" ]; then
echo "fairvisor: GeoIP2 databases missing in /etc/geoip2/" >&2
echo "fairvisor: Geo-based and ASN-based rate limiting are enabled in config, but databases are missing." >&2
echo "fairvisor: Please mount MaxMind .mmdb files to /etc/geoip2/ to continue." >&2
exit 1
fi

export FAIRVISOR_SHARED_DICT_SIZE
export FAIRVISOR_LOG_LEVEL
export FAIRVISOR_MODE
Expand Down
1 change: 1 addition & 0 deletions docker/nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ worker_shutdown_timeout 35s;
http {
resolver 127.0.0.11 ipv6=off valid=30s;
resolver_timeout 2s;

geo $is_tor_exit {
default 0;
include /etc/nginx/iplists/tor_exits.geo;
Expand Down
19 changes: 19 additions & 0 deletions docs/install/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,22 @@ Fairvisor is distributed as container images and installed as infrastructure run
- Standalone mode: mount a local policy bundle and set `FAIRVISOR_CONFIG_FILE`

Both modes are supported in production.

## GeoIP2 and ASN Database Setup

To enable geo-based and ASN-based rate limiting, Fairvisor requires MaxMind GeoLite2 (or equivalent) databases in `.mmdb` format.

### 1. Obtain databases
Download `GeoLite2-Country.mmdb` and `GeoLite2-ASN.mmdb` from MaxMind or a compatible source.

### 2. Mount databases to the container
Mount the directory containing the `.mmdb` files to `/etc/geoip2` in the Fairvisor container:

```bash
docker run -v /path/to/geoip-dbs:/etc/geoip2 ... ghcr.io/fairvisor/fairvisor-edge
```

The runtime will automatically detect the databases and reload them every 24 hours.

### 3. Verification
If the databases are missing, Nginx will fail to start. Ensure the worker process has read permissions for these files.
25 changes: 25 additions & 0 deletions spec/helpers/mock_ngx.lua
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ function _M.setup_time_mock()
}
end

function _M.setup_package_mock()
package.loaded["resty.maxminddb"] = {
initted = function() return true end,
init = function() return true end,
lookup = function() return nil end,
}
end

function _M.setup_ngx()
local time = _M.setup_time_mock()
local dict = _M.mock_shared_dict()
Expand All @@ -210,6 +218,23 @@ function _M.setup_ngx()
shared = {
fairvisor_counters = dict,
},
req = {
read_body = function() end,
get_body_data = function() return nil end,
get_body_file = function() return nil end,
get_headers = function() return {} end,
get_uri_args = function() return {} end,
},
var = {
request_method = "GET",
uri = "/",
host = "localhost",
remote_addr = "127.0.0.1",
geoip2_data_country_iso_code = nil,
asn = nil,
fairvisor_asn_type = nil,
is_tor_exit = nil,
},
log = function(...)
logs[#logs + 1] = { ... }
end,
Expand Down
4 changes: 4 additions & 0 deletions spec/integration/decision_api_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ runner:given("^the integration nginx environment is reset$", function(ctx)
get_uri_args = function()
return args
end,
read_body = function() end,
get_body_data = function()
return ctx.request_body
end,
}

ngx.var = {
Expand Down
57 changes: 57 additions & 0 deletions spec/unit/decision_api_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ local function _to_base64url(raw)
end

runner:given("^the decision api dependencies are initialized$", function(ctx)
mock_ngx.setup_package_mock()
mock_ngx.setup_ngx()

local headers = {}
Expand All @@ -65,13 +66,24 @@ runner:given("^the decision api dependencies are initialized$", function(ctx)
get_uri_args = function()
return uri_args
end,
read_body = function() end,
get_body_data = function()
return ctx.request_body
end,
get_body_file = function()
return ctx.request_body_file
end,
}

ngx.var = {
request_method = "GET",
uri = "/v1/decision",
host = "edge.internal",
remote_addr = "127.0.0.1",
geoip2_data_country_iso_code = nil,
asn = nil,
fairvisor_asn_type = nil,
is_tor_exit = nil,
}

ngx.header = {}
Expand Down Expand Up @@ -325,10 +337,45 @@ runner:given('^math random returns sequence "([^"]+)"$', function(ctx, values_cs
end
end)

runner:given('^the request body is "(.*)"$', function(ctx, body)
ctx.request_body = body
end)

runner:given('^the request body is in file "(.*)" with content "(.*)"$', function(ctx, path, content)
local f = io.open(path, "wb")
if f then
f:write(content)
f:close()
end
ctx.request_body_file = path
ctx.test_cleanup_files = ctx.test_cleanup_files or {}
ctx.test_cleanup_files[#ctx.test_cleanup_files + 1] = path
end)

runner:given('^the request method is "([^"]+)"$', function(_, method)
ngx.var.request_method = method
end)

runner:when("^I decode the jwt payload from authorization header$", function(ctx)
local auth = ngx.req.get_headers()["Authorization"]
ctx.claims = decision_api.decode_jwt_payload(auth)
end)
runner:then_('^request context body is "(.*)"$', function(ctx, expected)
assert.are.equal(expected, ctx.request_context.body)
end)

runner:then_('^request context body_hash is present$', function(ctx)
assert.is_not_nil(ctx.request_context.body_hash)
assert.are.equal(64, #ctx.request_context.body_hash) -- hex sha256 is 64 chars
end)

runner:then_('^request context body is nil$', function(ctx)
assert.is_nil(ctx.request_context.body)
end)

runner:then_('^request context body_hash is nil$', function(ctx)
assert.is_nil(ctx.request_context.body_hash)
end)

runner:when("^I build request context$", function(ctx)
ctx.request_context = decision_api.build_request_context()
Expand Down Expand Up @@ -572,6 +619,16 @@ runner:then_("^the test cleanup restores globals$", function(ctx)
math.random = ctx.original_random
end

if ctx.test_cleanup_files then
for _, path in ipairs(ctx.test_cleanup_files) do
os.remove(path)
end
ctx.test_cleanup_files = nil
end

ctx.request_body = nil
ctx.request_body_file = nil

os.getenv = ctx.original_getenv
end)

Expand Down
40 changes: 40 additions & 0 deletions spec/unit/features/decision_api.feature
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,46 @@ Feature: Decision API unit behavior
And request context host is "edge.internal"
And the test cleanup restores globals

Scenario: BUG-9 read request body in reverse proxy mode
Given the decision api dependencies are initialized
And the mode is "reverse_proxy"
And request method is "POST" and path is "/v1/chat"
And the request body is "{\"foo\":\"bar\"}"
When I build request context
Then request context body is "{\"foo\":\"bar\"}"
And request context body_hash is present
And the test cleanup restores globals

Scenario: BUG-9 read request body from file (fallback)
Given the decision api dependencies are initialized
And the mode is "reverse_proxy"
And request method is "POST" and path is "/v1/chat"
And the request body is in file "test_body.tmp" with content "{\"large\":\"body\"}"
When I build request context
Then request context body is "{\"large\":\"body\"}"
And request context body_hash is present
And the test cleanup restores globals

Scenario: BUG-9 do not read body in decision service mode
Given the decision api dependencies are initialized
And the mode is "decision_service"
And request method is "POST" and path is "/v1/chat"
And the request body is "{\"foo\":\"bar\"}"
When I build request context
Then request context body is nil
And request context body_hash is nil
And the test cleanup restores globals

Scenario: BUG-9 do not read body for GET requests
Given the decision api dependencies are initialized
And the mode is "reverse_proxy"
And request method is "GET" and path is "/v1/chat"
And the request body is "{\"foo\":\"bar\"}"
When I build request context
Then request context body is nil
And request context body_hash is nil
And the test cleanup restores globals

Rule: Access phase decision mapping
Scenario: Returns 503 when no bundle exists
Given the decision api dependencies are initialized
Expand Down
12 changes: 12 additions & 0 deletions spec/unit/features/loop_detector.feature
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ Feature: Loop detector module behavior
When I build two fingerprints with same query params in different key order
Then the two fingerprints are equal

Scenario: BUG-13 body hash influences fingerprint
Given the nginx mock environment is reset
When I build the fingerprint with method "POST" and path "/v1/chat" and body_hash "h1"
And I build the fingerprint again with method "POST" and path "/v1/chat" and body_hash "h2"
Then the two fingerprints are different

Scenario: BUG-13 same body hash results in same fingerprint
Given the nginx mock environment is reset
When I build the fingerprint with method "POST" and path "/v1/chat" and body_hash "h1"
And I build the fingerprint again with method "POST" and path "/v1/chat" and body_hash "h1"
Then the two fingerprints are equal

Rule: Config validation and fail-open behavior
Scenario: AC-10 validation rejects threshold less than 2
Given loop detection config has invalid threshold 1
Expand Down
12 changes: 12 additions & 0 deletions spec/unit/loop_detector_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ runner:when("^I build the fingerprint again with method GET and path /v1/chat an
ctx.second_fp = loop_detector.build_fingerprint("GET", "/v1/chat", { model = "gpt-4" }, nil, nil)
end)

runner:when(
"^I build the fingerprint with method \"([^\"]+)\" and path \"([^\"]+)\" and body_hash \"([^\"]+)\"$",
function(ctx, method, path, body_hash)
ctx.first_fp = loop_detector.build_fingerprint(method, path, nil, body_hash, nil)
end)

runner:when(
"^I build the fingerprint again with method \"([^\"]+)\" and path \"([^\"]+)\" and body_hash \"([^\"]+)\"$",
function(ctx, method, path, body_hash)
ctx.second_fp = loop_detector.build_fingerprint(method, path, nil, body_hash, nil)
end)

runner:when("^I build two fingerprints with same query params in different key order$", function(ctx)
ctx.first_fp = loop_detector.build_fingerprint("POST", "/v1/chat", { b = "2", a = "1" }, nil, nil)
ctx.second_fp = loop_detector.build_fingerprint("POST", "/v1/chat", { a = "1", b = "2" }, nil, nil)
Expand Down
Loading
Loading