Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
b660156ef7 Merge pull request 'make wantedScripts an ordered list' (#3)
Reviewed-on: #3
2025-11-23 20:17:27 +00:00
Claude
c01c6d0ba1 Change wantedScripts to ordered list format
This ensures scripts execute in the order they appear in the config file,
resolving the TODO in the README. The config format has been changed from
a table with script names as keys to an ordered list of script entries.

Changes:
- ctx.lua: Use ipairs() instead of pairs() to iterate in order
- config.lua: Update validation to handle new list structure
- conf.lua: Update example config to use new format
- README.md: Remove TODO and clarify execution order

New config format:
wantedScripts = {
    {name = 'script1', config = {...}},
    {name = 'script2', config = {...}}
}
2025-11-23 17:16:23 -03:00
b7f63801d4 add CLAUDE.md 2025-11-22 01:13:47 -03:00
08f17f812b log when config is found 2025-05-02 18:47:58 -03:00
6e9f673d91 return 401 2025-05-02 18:47:54 -03:00
6e3705a6df fix typos 2025-05-02 18:44:53 -03:00
8434492b18 add pleroma_restrict_unauthenticated_search.lua 2025-05-02 18:43:27 -03:00
2c1f155328 update README 2024-02-16 17:56:06 -03:00
84ff7bd1b6 update docs 2024-02-16 02:13:37 -03:00
7f8e86fbd8 lint pass 2023-10-26 22:19:08 -03:00
84cb21b26a Merge pull request 'add config file validation' (#2) from config-validation into mistress
Reviewed-on: #2
2022-12-07 18:50:30 +00:00
11 changed files with 275 additions and 56 deletions

135
CLAUDE.md Normal file
View file

@ -0,0 +1,135 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
aproxy is an Activity Pub Reverse Proxy Framework built on OpenResty (NGINX + LuaJIT). It provides a modular script system to add filtering and protection capabilities in front of ActivityPub implementations.
## Development Commands
### Testing
```sh
# Install test dependencies (only needed once)
make testdeps
eval (luarocks-5.1 path --bin)
# Run test suite
make test
```
The test suite uses luajit and luaunit for testing. Tests are located in the `tests/` directory.
## Architecture
### Core Components
- **main.lua**: Entry point with two hooks:
- `init()`: Validates configuration at startup
- `access()`: Called on every request to execute the filter chain
- **config.lua**: Configuration system
- Searches for `conf.lua` in paths: `.;/etc/aproxy` (or `$APROXY_CONFIG_PATH`)
- Provides schema validation framework
- Schema supports types: `string`, `number`, `table`, `list` (array of values)
- **ctx.lua**: Request context and filter chain executor
- Loads scripts from config and builds `compiled_chain`
- Matches request URIs against script PCRE regexes
- Executes matching callbacks sequentially
- If any callback returns a status code, request is terminated with that response
- **util.lua**: Lua standard library extensions
- `table.readonly()`: Create read-only table wrapper
- `table.pprint()`: Pretty-print nested tables
- `string.split()`: Split strings by separator
### Script Module Structure
Scripts live in `scripts/` and must export:
```lua
return {
name = 'ModuleName',
author = 'email',
title = 'Short Title',
description = [[Long description]],
version = 1,
-- Called once at startup with config
-- Return value becomes module state
init = function(cfg)
return state
end,
-- Map of PCRE regex patterns to callback functions
callbacks = {
['/api/path'] = function(cfg, state)
-- Return nil to allow request
-- Return status_code, body to block request
return nil
end
},
-- Schema for validating this module's config
config = {
['field_name'] = {
type = 'string|number|table|list',
schema = {...}, -- for table/list types
description = 'field description'
}
}
}
```
**Important**: Module configs are made read-only via `table.readonly()` before being passed to callbacks. Attempting to modify them will error.
### Request Flow
1. OpenResty calls `aproxy.main.access()` on each request
2. `ctx:onRequest()` iterates through `compiled_chain`
3. For each script, check if request URI matches any callback regex
4. Execute matching callbacks with their config and state
5. If callback returns `(status_code, body)`, terminate request with that response
6. If callback returns `nil`, continue to next callback
7. If no callbacks block the request, pass through to backend
### Testing Framework
Tests use a mock `ngx` object (created by `createNgx()` in test.lua) that simulates OpenResty's API:
- `ngx.var.uri`: Request URI
- `ngx.req.get_uri_args()`: Query parameters
- `ngx.req.get_headers()`: Request headers
- `ngx.status`, `ngx.say()`, `ngx.exit()`: Response control
- `ngx.re.match()`: PCRE regex matching via rex_pcre2
Use `setupTest(module_path, config)` to initialize a module for testing and `setupFakeRequest(path, options)` to simulate requests.
To run the test suite:
```sh
# needed only once ever to setup the environment
make testdeps
# run once on any new shell
eval (luarocks-5.1 path --bin)
# actually run suite
make test
```
## Configuration
The default config file (`conf.lua`) structure:
```lua
return {
version = 1,
wantedScripts = {
['script_name'] = {
-- script-specific config matching its schema
}
}
}
```
Scripts are loaded from `scripts/{script_name}.lua` based on keys in `wantedScripts`.

View file

@ -19,8 +19,37 @@ It is an effective replacement to your NGINX installation, but you can have
them coexisting (say, NGINX on port 80, OpenResty being reverse proxied by them coexisting (say, NGINX on port 80, OpenResty being reverse proxied by
NGINX on port 8069, though I wouldn't recommend it in production environments). NGINX on port 8069, though I wouldn't recommend it in production environments).
### how does it work
aproxy has two "hooks" into openresty:
- initialization of the lua vm
- callback for every incoming request
initialization will run validation of your configuration file and check
if it is valid. if it is not you will see logs emitted about what failed
when a request comes in, the scripts declared in the aproxy config file will
be executed sequentially in the order they appear in the configuration.
each script has two types of callbacks: init and request
init callbacks are called when initializing the request, so that the script
may do some conversion or transform the config data into a more performant
in-memory structure.
request callbacks are called on each request, as directed by the main script.
scripts define which paths they want to attach to (via PCRE regex), and so
they can do their own filtering.
look at `scripts/webfinger_allowlist.lua` for an example of how this looks
in a simple form.
### actually installing aproxy ### actually installing aproxy
- get openresty installed
- keep in mind that the specifics of configuring openresty for a double reverse proxy setup aren't included here.
- instructions here are for aproxy's setup in an existing openresty installation
```sh ```sh
mkdir /opt mkdir /opt
git clone https://gitdab.com/luna/aproxy git clone https://gitdab.com/luna/aproxy
@ -28,6 +57,9 @@ git clone https://gitdab.com/luna/aproxy
cd aproxy cd aproxy
mkdir /etc/aproxy mkdir /etc/aproxy
cp ./conf.lua /etc/aproxy/conf.lua cp ./conf.lua /etc/aproxy/conf.lua
# keep in mind the default configuration will lead to your users not being discovered at all,
# it is provided as an example for you to modify.
$EDITOR /etc/aproxy/conf.lua $EDITOR /etc/aproxy/conf.lua
``` ```
@ -51,23 +83,26 @@ http {
You need to do the following: You need to do the following:
- Configure OpenResty package path so that it can call aproxy. - Configure OpenResty package path so that it can call aproxy.
- Insert aproxy as a callback on `location / {` block - insert aproxy hooks for initialization and for callbacks on every request
It'll look something like this: It'll look something like this if you use a single `location /` block:
```nginx ```nginx
# set this to 'on' after you have tested that it actually works.
# once you do that, performance will be increased
# while the friction to quickly debug aproxy will also be increased
lua_code_cache off;
lua_package_path '/opt/?.lua;/opt/aproxy/?.lua;;';
init_by_lua_block {
require("aproxy.main").init()
}
http { http {
lua_package_path '/opt/?.lua;/opt/aproxy/?.lua;;';
server { server {
server_name example.com; server_name example.com;
location / { location / {
# set this to 'on' after you have tested that it actually works.
# once you do that, performance will be increased
# while the friction to quickly debug aproxy will also be increased
lua_code_cache off;
access_by_lua_block { access_by_lua_block {
require("aproxy.main")() require("aproxy.main").access()
} }
proxy_http_version 1.1; proxy_http_version 1.1;

View file

@ -1,6 +1,11 @@
return { return {
version = 1, version = 1,
wantedScripts = { wantedScripts = {
['webfinger_allowlist'] = {accounts = {"example@example.com"}} {
name = 'webfinger_allowlist',
config = {
accounts = {"example@example.com"}
}
}
} }
} }

View file

@ -2,22 +2,12 @@ local env_config_path = os.getenv('APROXY_CONFIG_PATH')
local DEFAULT_CONFIG_PATH = ".;/etc/aproxy" local DEFAULT_CONFIG_PATH = ".;/etc/aproxy"
local config_path = env_config_path or DEFAULT_CONFIG_PATH local config_path = env_config_path or DEFAULT_CONFIG_PATH
function mysplit (inputstr, sep)
if sep == nil then
sep = "%s"
end
local t={}
for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
table.insert(t, str)
end
return t
end
local function findConfigFile() local function findConfigFile()
for _, config_directory in ipairs(mysplit(config_path, ";")) do for _, config_directory in ipairs(string.split(config_path, ";")) do
local possible_config_path = config_directory .. "/" .. "conf.lua" local possible_config_path = config_directory .. "/" .. "conf.lua"
local fd, res = io.open(possible_config_path, "rb") local fd, res = io.open(possible_config_path, "rb")
if fd then if fd then
log('config found at ' .. possible_config_path)
local data = fd:read("*a") local data = fd:read("*a")
fd:close() fd:close()
return data return data
@ -37,7 +27,7 @@ end
local function fakeTableSchema(value_schema) local function fakeTableSchema(value_schema)
return setmetatable({ __list = true }, { return setmetatable({ __list = true }, {
__index = function (self, key) __index = function ()
return value_schema return value_schema
end end
}) })
@ -45,8 +35,8 @@ end
local SPECIAL_KEYS = {__list = true} local SPECIAL_KEYS = {__list = true}
local function validateSchema(schema, input, errors) local function validateSchema(schema, input, errors_in)
local errors = errors or {} local errors = errors_in or {}
if schema.__list then if schema.__list then
-- generate full schema for lists that are same size as input -- generate full schema for lists that are same size as input
for k, _ in pairs(input) do schema[k] = schema.__schema_value end for k, _ in pairs(input) do schema[k] = schema.__schema_value end
@ -94,7 +84,9 @@ end
local function validateConfigFile(config_object) local function validateConfigFile(config_object)
local all_schema_errors = {} local all_schema_errors = {}
for module_name, module_config in pairs(config_object.wantedScripts) do for _, script_entry in ipairs(config_object.wantedScripts) do
local module_name = script_entry.name
local module_config = script_entry.config
local module_manifest = require('scripts.' .. module_name) local module_manifest = require('scripts.' .. module_name)
local config_schema = module_manifest.config local config_schema = module_manifest.config
local schema_errors = validateSchema(config_schema, module_config) local schema_errors = validateSchema(config_schema, module_config)
@ -105,12 +97,8 @@ local function validateConfigFile(config_object)
return all_schema_errors return all_schema_errors
end end
local function writeSchemaErrors(errors, out) local function loadConfigFile(options_in)
out('sex') local options = options_in or {}
end
local function loadConfigFile(options)
local options = options or {}
local config_file_data = assert(findConfigFile(), 'no config file found, config path: ' .. config_path) local config_file_data = assert(findConfigFile(), 'no config file found, config path: ' .. config_path)
local config_file_function = assert(loadstring(config_file_data)) local config_file_function = assert(loadstring(config_file_data))

16
ctx.lua
View file

@ -1,3 +1,5 @@
local util = require("util")
function log(msg) function log(msg)
ngx.log(ngx.STDERR, tostring(msg)) ngx.log(ngx.STDERR, tostring(msg))
end end
@ -13,13 +15,16 @@ function ctx:loadFromConfig(conf)
ctx:loadChain() ctx:loadChain()
end end
function ctx:loadChain() function ctx:loadChain()
self.compiled_chain = {} self.compiled_chain = {}
for module_name, module_config in pairs(self._wanted_scripts) do for _, script_entry in ipairs(self._wanted_scripts) do
local module_name = script_entry.name
local module_config = script_entry.config
local module = require('scripts.' .. module_name) local module = require('scripts.' .. module_name)
local module_state = module.init(module_config) local module_config_readonly = table.readonly(module_config)
-- TODO is it possible to make module_config readonly? local module_state = module.init(module_config_readonly)
table.insert(self.compiled_chain, {module, module_config, module_state}) table.insert(self.compiled_chain, {module, module_config_readonly, module_state})
end end
end end
@ -32,7 +37,7 @@ function ctx:onRequest()
local module, module_config, state = unpack(filter) local module, module_config, state = unpack(filter)
for callback_regex, callback_function in pairs(module.callbacks) do for callback_regex, callback_function in pairs(module.callbacks) do
local match, error = ngx.re.match(request_uri, callback_regex) local match = ngx.re.match(request_uri, callback_regex)
if match then if match then
table.insert(callbacks_to_call, {module, callback_function, module_config, state}) table.insert(callbacks_to_call, {module, callback_function, module_config, state})
end end
@ -47,6 +52,7 @@ function ctx:onRequest()
ngx.status = status_code ngx.status = status_code
ngx.say(body or "request denied") ngx.say(body or "request denied")
ngx.exit(status_code) ngx.exit(status_code)
return
end end
end end
end end

View file

@ -1,11 +1,3 @@
-- function loadConfig()
-- -- TODO load config_path
-- return require("./config.lua")
-- end
--
-- local config = loadConfig()
local ctx = require('ctx') local ctx = require('ctx')
local config = require('config') local config = require('config')
require('util') require('util')

View file

@ -1,10 +1,10 @@
lua_code_cache off;
init_by_lua_block { init_by_lua_block {
require("aproxy.main").init() require("aproxy.main").init()
} }
server { server {
listen 80; listen 80;
lua_code_cache off;
location / { location / {
default_type text/html; default_type text/html;

View file

@ -0,0 +1,35 @@
local function searchInit(cfg)
return {} -- no ctx
end
local function searchCallback(cfg, _ctx)
local h, err = ngx.req.get_headers()
if err == "truncated" then
return 400, 'too many headers'
end
local authheader = h["authorization"]
if authheader == nil then
return 401, "requires authentication"
else
return nil
end
end
return {
name='PleromaRestrictUnauthenticatedSearch',
author='luna@l4.pm',
title='restrict unauth search',
description=[[
Search can be a DoS vector. restrict it without Authorization header.
Useful for small instances.
]],
version=1,
init=searchInit,
callbacks = {
['/api/v2/search'] = searchCallback
},
config={},
}

View file

@ -1,4 +1,4 @@
function webfingerInit(cfg) local function webfingerInit(cfg)
local accounts_set = {} local accounts_set = {}
for _, account in ipairs(cfg.accounts) do for _, account in ipairs(cfg.accounts) do
accounts_set["acct:" .. account] = true accounts_set["acct:" .. account] = true
@ -6,7 +6,7 @@ function webfingerInit(cfg)
return accounts_set return accounts_set
end end
function webfingerCallback(cfg, accounts_set) local function webfingerCallback(cfg, accounts_set)
local args, err = ngx.req.get_uri_args() local args, err = ngx.req.get_uri_args()
if err == "truncated" then if err == "truncated" then
return 400, 'uri args too long' return 400, 'uri args too long'

View file

@ -64,7 +64,7 @@ function setupTest(module_require_path, input_config)
local count = table.pprint(schema_errors) local count = table.pprint(schema_errors)
lu.assertIs(count, 0) lu.assertIs(count, 0)
state = module.init(input_config) local state = module.init(input_config)
ctx.compiled_chain = { ctx.compiled_chain = {
{module, input_config, state} {module, input_config, state}
} }
@ -74,9 +74,9 @@ end
function onRequest() function onRequest()
ctx:setWantedScripts() ctx:setWantedScripts()
local ctx = require('ctx') local context = require('ctx')
do do
ctx:onRequest() context:onRequest()
end end
end end

View file

@ -4,11 +4,11 @@ function table.len(t)
return count return count
end end
function table.pprint(t, options, ident, total_count) function table.pprint(t, options_in, ident_in, total_count_in)
local ident = ident or 0 local ident = ident_in or 0
local total_count = total_count or 0 local total_count = total_count_in or 0
local options = options or {} local options = options_in or {}
local print_function = options.call or print local print_function = options.call or print
if type(t) == 'table' then if type(t) == 'table' then
local count = 0 local count = 0
@ -18,7 +18,7 @@ function table.pprint(t, options, ident, total_count)
total_count = table.pprint(v, options, ident + 1, total_count) total_count = table.pprint(v, options, ident + 1, total_count)
end end
if count == 0 then if count == 0 then
--print('<empty table>') print_function('{}')
end end
else else
print_function(string.rep('\t', ident) .. tostring(t)) print_function(string.rep('\t', ident) .. tostring(t))
@ -26,3 +26,26 @@ function table.pprint(t, options, ident, total_count)
end end
return total_count return total_count
end end
function table.readonly(t)
return setmetatable({}, {
__index = t,
__newindex = function ()
error("Attempt to modify read-only table")
end,
__metatable = false
});
end
function string.split(inputstr, sep)
if sep == nil then
sep = "%s"
end
local t={}
for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
table.insert(t, str)
end
return t
end