diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..66f46f8 --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index 02e33d7..dae4deb 100644 --- a/README.md +++ b/README.md @@ -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 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 +- 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 mkdir /opt git clone https://gitdab.com/luna/aproxy @@ -28,6 +57,9 @@ git clone https://gitdab.com/luna/aproxy cd aproxy mkdir /etc/aproxy 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 ``` @@ -51,23 +83,26 @@ http { You need to do the following: - 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 +# 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 { - lua_package_path '/opt/?.lua;/opt/aproxy/?.lua;;'; - server { server_name example.com; 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 { - require("aproxy.main")() + require("aproxy.main").access() } proxy_http_version 1.1; diff --git a/conf.lua b/conf.lua index ee662d7..13e9e98 100644 --- a/conf.lua +++ b/conf.lua @@ -1,6 +1,11 @@ return { version = 1, wantedScripts = { - ['webfinger_allowlist'] = {accounts = {"example@example.com"}} + { + name = 'webfinger_allowlist', + config = { + accounts = {"example@example.com"} + } + } } } diff --git a/config.lua b/config.lua index 278ee9b..a8417a2 100644 --- a/config.lua +++ b/config.lua @@ -2,22 +2,12 @@ local env_config_path = os.getenv('APROXY_CONFIG_PATH') local DEFAULT_CONFIG_PATH = ".;/etc/aproxy" 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() - 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 fd, res = io.open(possible_config_path, "rb") if fd then + log('config found at ' .. possible_config_path) local data = fd:read("*a") fd:close() return data @@ -37,7 +27,7 @@ end local function fakeTableSchema(value_schema) return setmetatable({ __list = true }, { - __index = function (self, key) + __index = function () return value_schema end }) @@ -45,8 +35,8 @@ end local SPECIAL_KEYS = {__list = true} -local function validateSchema(schema, input, errors) - local errors = errors or {} +local function validateSchema(schema, input, errors_in) + local errors = errors_in or {} if schema.__list then -- generate full schema for lists that are same size as input for k, _ in pairs(input) do schema[k] = schema.__schema_value end @@ -94,7 +84,9 @@ end local function validateConfigFile(config_object) 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 config_schema = module_manifest.config local schema_errors = validateSchema(config_schema, module_config) @@ -105,12 +97,8 @@ local function validateConfigFile(config_object) return all_schema_errors end -local function writeSchemaErrors(errors, out) - out('sex') -end - -local function loadConfigFile(options) - local options = options or {} +local function loadConfigFile(options_in) + local options = options_in or {} local config_file_data = assert(findConfigFile(), 'no config file found, config path: ' .. config_path) local config_file_function = assert(loadstring(config_file_data)) diff --git a/ctx.lua b/ctx.lua index 78e3239..2ab470e 100644 --- a/ctx.lua +++ b/ctx.lua @@ -1,3 +1,5 @@ +local util = require("util") + function log(msg) ngx.log(ngx.STDERR, tostring(msg)) end @@ -13,13 +15,16 @@ function ctx:loadFromConfig(conf) ctx:loadChain() end + function ctx:loadChain() 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_state = module.init(module_config) - -- TODO is it possible to make module_config readonly? - table.insert(self.compiled_chain, {module, module_config, module_state}) + local module_config_readonly = table.readonly(module_config) + local module_state = module.init(module_config_readonly) + table.insert(self.compiled_chain, {module, module_config_readonly, module_state}) end end @@ -32,7 +37,7 @@ function ctx:onRequest() local module, module_config, state = unpack(filter) 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 table.insert(callbacks_to_call, {module, callback_function, module_config, state}) end @@ -47,6 +52,7 @@ function ctx:onRequest() ngx.status = status_code ngx.say(body or "request denied") ngx.exit(status_code) + return end end end diff --git a/main.lua b/main.lua index b0cf8ff..0470522 100644 --- a/main.lua +++ b/main.lua @@ -1,11 +1,3 @@ - --- function loadConfig() --- -- TODO load config_path --- return require("./config.lua") --- end --- --- local config = loadConfig() - local ctx = require('ctx') local config = require('config') require('util') diff --git a/nginx.conf b/nginx.conf index 4b6e8bf..4904508 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,10 +1,10 @@ +lua_code_cache off; init_by_lua_block { require("aproxy.main").init() } server { listen 80; - lua_code_cache off; location / { default_type text/html; diff --git a/scripts/pleroma_restrict_unauthenticated_search.lua b/scripts/pleroma_restrict_unauthenticated_search.lua new file mode 100644 index 0000000..965f311 --- /dev/null +++ b/scripts/pleroma_restrict_unauthenticated_search.lua @@ -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={}, +} diff --git a/scripts/webfinger_allowlist.lua b/scripts/webfinger_allowlist.lua index 41d5c80..7e7355f 100644 --- a/scripts/webfinger_allowlist.lua +++ b/scripts/webfinger_allowlist.lua @@ -1,4 +1,4 @@ -function webfingerInit(cfg) +local function webfingerInit(cfg) local accounts_set = {} for _, account in ipairs(cfg.accounts) do accounts_set["acct:" .. account] = true @@ -6,7 +6,7 @@ function webfingerInit(cfg) return accounts_set end -function webfingerCallback(cfg, accounts_set) +local function webfingerCallback(cfg, accounts_set) local args, err = ngx.req.get_uri_args() if err == "truncated" then return 400, 'uri args too long' diff --git a/test.lua b/test.lua index 968117b..ded2270 100644 --- a/test.lua +++ b/test.lua @@ -64,7 +64,7 @@ function setupTest(module_require_path, input_config) local count = table.pprint(schema_errors) lu.assertIs(count, 0) - state = module.init(input_config) + local state = module.init(input_config) ctx.compiled_chain = { {module, input_config, state} } @@ -74,9 +74,9 @@ end function onRequest() ctx:setWantedScripts() - local ctx = require('ctx') + local context = require('ctx') do - ctx:onRequest() + context:onRequest() end end diff --git a/util.lua b/util.lua index 4db2229..5c0b74f 100644 --- a/util.lua +++ b/util.lua @@ -4,11 +4,11 @@ function table.len(t) return count end -function table.pprint(t, options, ident, total_count) - local ident = ident or 0 - local total_count = total_count or 0 +function table.pprint(t, options_in, ident_in, total_count_in) + local ident = ident_in 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 if type(t) == 'table' then 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) end if count == 0 then - --print('') + print_function('{}') end else print_function(string.rep('\t', ident) .. tostring(t)) @@ -26,3 +26,26 @@ function table.pprint(t, options, ident, total_count) end return total_count 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 + +