Skip to content

Repository files navigation

PSP Callback Router (OpenResty/Lua)

Routes incoming PSP callback requests to the correct upstream App server. It reads a composite transaction ID from the request body, decodes which environment and customer it belongs to, and proxies to the right server.

Runs on the stage server alongside stock Nginx. No Docker, no Redis, no extra runtimes.


Table of Contents


Prerequisites

  • Debian/Ubuntu server with root/sudo access, and rsync installed (used by deploy.sh)

  • Stock Nginx already running, with a server { } block that terminates TLS for the domain your PSPs post callbacks to. That block is where you add one include line.

  • A PHP front-controller app in that same server block. The router's passthrough branch ends in rewrite ^ /index.php last;, so the block must be able to serve /index.php — i.e. it contains something like:

    root /var/www/app/public;location / {try_files$uri$uri/ /index.php?$query_string;}location~\.php$ {include fastcgi_params;fastcgi_pass unix:/run/php/php8.2-fpm.sock;fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;}

    Not running a PHP front controller? This is not a blocker, but you must edit the passthrough before deploying — see If your app is not PHP.

  • Changes to your own application. Nothing in this repository generates transaction IDs. The router can only work if your app embeds routing information in the reference it sends to the PSP — see Integrating your application. Treat that section as mandatory reading, not background.

  • /etc/hosts entries for stage/local upstream hostnames (managed from the admin UI)


Quick Start

On the stage server:

# 1. Install OpenResty (one-time setup)
sudo bash setup.sh
# 2. Deploy code and configs
sudo bash deploy.sh
# 3. Add the include line to your nginx server block (manual step, see below)# 4. Check the config parses, then reload
sudo nginx -t && sudo nginx -s reload
# 5. Review your URI patterns in the admin UI. Port 8081 is bound to loopback,# so browse it from the server, or tunnel it from your machine:# ssh -L 8081:127.0.0.1:8081 user@server -> http://localhost:8081/admin/settings# (the shipped patterns are EXAMPLES from another deployment — see step 5 below)# 6. Make OpenResty survive a reboot.# deploy.sh already started a master directly, which would make `--now`# fail on the port. Register the unit for boot, and let the running# process keep serving until the next restart.
sudo systemctl enable openresty
# 7. Verify
sudo bash test.sh

About step 7's exit code. On a brand-new install test.sh exits 2, not 0. That is expected: five checks pass, but the end-to-end routing check is skipped because there is no PSP rule or route to test with yet. It exits non-zero deliberately so a CI gate cannot mistake "nothing configured" for "verified". Add a PSP rule (/admin/psp-rules) and a route (/admin/routes), then re-run — it exits 0 once routing is actually proven.

ExitMeaning
0Everything checked, including end-to-end routing
1A check failed — the install is broken
2Nothing failed, but routing was never proven (fresh install)

Important: The code runs from /usr/local/openresty/nginx/, not from the git repo. After every git pull, you must re-deploy to apply changes:

git pull
sudo bash deploy.sh

Step 3 detail — wiring it into your existing Nginx

This is the only manual install step, and the easiest one to get wrong.

Find the right server block. It is the one that terminates TLS for the domain your PSPs post callbacks to. On Debian/Ubuntu it is normally a file under /etc/nginx/sites-enabled/:

sudo grep -rl 'server_name .*your-callback-domain' /etc/nginx/sites-enabled/ /etc/nginx/conf.d/

Add one line inside that server { } block — not in http { }, because the snippet contains location blocks:

server{listen443ssl;server_name your-callback-domain.com; # ... existing config ...include /etc/nginx/snippets/psp-router.conf; # <-- ADD THIS LINE
 # ... rest of config ...}

Then confirm it parses and that the block you edited is the one that actually won:

sudo nginx -t
sudo nginx -T | grep -n 'psp-router'

What the snippet adds

deploy.sh installs dist/snippets/psp-router-proxy.conf as /etc/nginx/snippets/psp-router.conf. It injects two location blocks into your server:

location~ ^/api/ { # Prevent infinite loop if routed upstream is on the same serverif($http_x_psp_routed = "1"){rewrite ^ /index.php last;}proxy_passhttp://127.0.0.1:8081;proxy_intercept_errors on; # Without these the routed upstream sees every PSP callback as coming from # 127.0.0.1 over plain HTTP.proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # 460 = OpenResty says "not a PSP callback, let the local app handle it"error_page460 = @psp_passthrough;}location @psp_passthrough {rewrite ^ /index.php last;}

proxy_intercept_errors applies to this whole location, so any error_page directives inherited from your server/http level will also intercept genuine upstream responses (404, 500, …) and replace them. Scope those directives if that matters to you.

This captures ALL /api/ traffic on that server block, not just PSP callbacks. Every /api/ URL is proxied through OpenResty, and all of them return 502 if OpenResty is stopped (only status 460 is intercepted, not 502). If you only want a subset, narrow the location ~ ^/api/ regex in dist/snippets/psp-router-proxy.conf — e.g. location ~ ^/api/[0-9.]+/(notification|redirect)/ — and re-run deploy.sh.

Location matching order

"Order matters" has a specific mechanism behind it:

  • Nginx evaluates regex locations top-to-bottom and takes the first match. So if you have your own location ~ ...^/api... blocks that must keep priority — for example static proxy_pass rules for PSPs you do not want Lua-routed — they must appear above the include line.
  • A prefix match with ^~, or an exact= match, on an /api/ path beats this regex location outright. If one exists, the router is never reached and you will see "health check works but nothing routes". Remove or narrow it.
  • Verify the resulting order with sudo nginx -T | grep -n 'location'.

If your app is not a PHP front controller

Both branches above end in rewrite ^ /index.php last;. Passthrough is the majority path — unknown PSP, unparseable body, no matching route, maintenance mode, and all non-PSP /api/ traffic — so if your app is Node, Django, Rails, or PHP with a different entry point, every one of those requests breaks after install.

Edit bothrewrite ^ /index.php last; lines (one in each branch) in dist/snippets/psp-router-proxy.conf. For a Node app on port 3000, the passthrough becomes:

location @psp_passthrough {proxy_passhttp://127.0.0.1:3000;}

Edit the file in the repo, not the deployed copydeploy.sh overwrites /etc/nginx/snippets/psp-router.conf on every run. Then re-run sudo bash deploy.sh.

Step 5 detail — URI patterns decide what is even considered

A request is only routed if its URI matches one of the URI patterns in Settings. The patterns shipped in dist/lua/psp_handlers/core/settings.json are the ones the original deployment used — they are examples, not universal defaults, and they will not match your callback URLs unless your API happens to use the same shapes:

^/api/0%.1/user/transfer/deposit/confirm/([a-zA-Z0-9_-]+)
^/api/0%.1/user/transfer/deposit/return/([a-zA-Z0-9_-]+)
^/api/0%.1/transfer/([a-zA-Z0-9_-]+)/notification
^/api/1%.0/redirect/([a-zA-Z0-9_-]+)
^/api/1%.0/notification/([a-zA-Z0-9_-]+)

Rules for writing your own at http://<server>:8081/admin/settings:

  • They are Lua patterns, not regexes. A literal . must be escaped as %.
  • Each must contain exactly one capture group(...), and it must capture the PSP name — that name is then looked up in PSP Rules. Patterns without a capture group are rejected by the Settings UI.

If the pattern list is empty, the router matches nothing and passes every request through silently — no routing-log entry and no error-log line, because non-matching requests are deliberately not logged. /health still returns 200 and the admin UI still loads, so an install with no patterns looks completely healthy while routing nothing. This is the single most common cause of "it's running but nothing routes".


How It Works

PSP Provider sends callback (POST /api/...)
|
v
Stock Nginx (port 443)
|
|-- your own /api/ location blocks, if any --> YES: they win (OpenResty not involved)
| NO: continue
|-- psp-router.conf --> forwards to OpenResty (port 8081)
|
v
OpenResty (port 8081) runs handler.lua
|
|-- 1. Extract PSP name from URI (e.g. /api/1.0/notification/acmepay -> "acmepay")
|-- 2. Is PSP in psp_rules.json? NO -> exit 460 (passthrough)
|-- 3. Read JSON/XML body BAD -> exit 460 (passthrough)
|-- 4. Decode composite ID from body FAIL -> exit 460 (passthrough)
|-- 5. Map env_code (1=stage, 2=cloud, 3=local) UNKNOWN -> exit 460 (passthrough)
|-- 6. Look up route by "env_type:key" NO MATCH -> exit 460 (passthrough)
|-- 7. Resolve hostname to IP (/etc/hosts) FAIL -> exit 460 (passthrough)
|-- 8. Set proxy target
|
v
Target App Server receives the request

"Passthrough" means handler.lua exits with HTTP status 460. Stock Nginx intercepts this via error_page 460 and rewrites to /index.php, so your local application handles the request normally. No proxy loop is involved. An X-PSP-Routed header prevents infinite loops only in the edge case where a routed upstream is on the same server.

Passthrough is the default outcome, not the exception — it covers unknown PSPs, unparseable bodies, IDs that decode to no route, maintenance mode, and every non-PSP /api/ request on the vhost. That is why the /index.php target has to be right for your stack; see If your app is not a PHP front controller.


Environment Types Explained

Your application embeds a composite ID in every transaction reference it sends to the PSP. When the PSP echoes that reference back in its callback, the ID tells the router where the request came from. Implementing this is your side of the contract — see Integrating your application.

Composite ID Format

[env_code][prefix_len][routing_key][random_remaining_digits]
PositionFieldWidthMeaning
1env_codeexactly 1 char1 = stage, 2 = cloud, 3 = local. Anything else is rejected → passthrough
2prefix_lenexactly 1 charThe number of characters routing_key occupies — not its value. 19
3 … 2+prefix_lenrouting_keyprefix_len charsDecimal digits; leading zeros allowed
remainderrandom suffixsee limit belowWhatever makes the reference unique

Three constraints that are easy to miss and fail silently:

prefix_len must equal the character width of the key as written. The router slices exactly prefix_len characters and never checks that the slice ends where the key ends. crc32 % 1000000 is under 100000 roughly 10% of the time — for example crc32('https://demo-tenant2.example.cloud') % 1000000 = 66708, five digits. Emit prefix_len = 6 for that key and the router reads 66708plus the first digit of your random suffix, then looks up a key that either does not exist (silent passthrough) or belongs to a different tenant (callback delivered to the wrong server).

Pick one strategy and never mix them: variable width (len = strlen(key)) or fixed width (key = zero-pad to 6, prefix_len = 6 always). Both work, because the router runs tonumber() on the slice — so 006708 and 6708 both resolve to cloud:6708. Because prefix_len is a single character, the key can be at most 9 digits.

Keep the whole composite ID to 14 characters or fewer. The router stringifies whatever the JSON decoder produced. If the PSP echoes the reference as an unquoted JSON number, a 15-digit value arrives as 2.6931942777333e+14; parsing then reads . as prefix_len, fails, and every callback passes through silently with only a WARN in error.log. Budget: 1 + 1 + up to 9 + suffix ≤ 14.

The ID must survive the round trip. You send it to the PSP as the reference/order field, and the PSP must return it in the callback body at a dot-path you configure on /admin/psp-rules. If the PSP truncates, reformats, or does not echo the reference, the router cannot route that PSP at all.

Note: The composite ID prepends routing metadata to the transaction ID, which means IDs are no longer sequentially orderable by creation time. Sorting or range queries on the transaction_id column will not reflect chronological order. This is an acceptable trade-off as the router is used on test/staging environments only.

There are three environment types:

1. Stage (env_code = 1)

For instances that share one staging database, the routing key is simply an integer you choose to identify each instance, and that you register on the Stage Routes page. The router does not validate or derive it — it is a plain table lookup, so any integer works as long as your app emits the same one you registered.

In the original deployment this was the customer_id column of the shared database, which is where the name comes from; you can use a tenant ID, a box number, or just 1, 2, 3.

Example ID: 13126999888
| | |
| | +-- routing_key = 126 (customer_id)
| +-- prefix_len = 3 (routing_key is 3 digits)
+-- env_code = 1 (stage)
Router looks up: "stage:126"

2. Cloud (env_code = 2)

Cloud environments may share customer IDs, so the routing key is a hash of the normalized full URL exactly as registered on the Cloud Routes page:

routing_key = crc32_unsigned("https://" + <host and path as registered>) % 1000000

The admin page normalizes what you paste: scheme forced to https://, one trailing slash stripped, path and port kept if present. Register cloud environments as a bare https://<host> with no path or port and the rule stays simple.

Example ID: 26404909777333
| | |
| | +-- routing_key = 404909
| +-- prefix_len = 6
+-- env_code = 2 (cloud)
Router looks up: "cloud:404909"

That example is real and reproducible: crc32('https://demo-tenant1.example.cloud') % 1000000 = 404909.

Cloud-specific behavior. For env_code = 2 the router rewrites the upstream as:

<cloud_scheme><cloud_host_prefix><registered-host><cloud_uri_prefix><original URI>

which with the shipped defaults produces:

http://api-<registered-host>/app<original URI>

— an api- hostname prefix, a downgrade to plaintext HTTP for the upstream hop, and the application assumed to be mounted under /app. These were conventions of the original deployment (api- bypassed a CDN; /app was the platform's mount path), so they are probably wrong for you.

All three are configurable from Settings → Cloud Upstream in the admin UI (or directly in settings.json, a server-side file that deploy.sh never overwrites), so your values survive every deploy:

KeyDefaultMeaning
cloud_host_prefixapi-Prepended to the registered hostname
cloud_schemehttp://Scheme for the upstream hop
cloud_uri_prefix/appPrepended to the request URI
{
"cloud_host_prefix": "",
"cloud_scheme": "https://",
"cloud_uri_prefix": ""
}

The example above disables all three rewrites, so a cloud route goes straight to https://<registered-host><original URI>. An empty prefix is honoured — it means "prepend nothing", not "use the default". An empty scheme is rejected with a 400, because proxy_pass without a scheme is a hard 500 on every routed request. Reload OpenResty after changing them (sudo /usr/local/openresty/bin/openresty -s reload); the Request Tester's dry run reads the same settings, so it always shows the target you will actually get.

If you have no cloud tier, ignore env_code = 2 entirely.

3. Local Dev (env_code = 3)

Local devs have their own database, so customer IDs can collide. The routing key is a hash of the bare hostname — no scheme, no path, no port, no trailing slash:

routing_key = crc32_unsigned(<bare hostname>) % 1000000
Example ID: 36244048185920
| | |
| | +-- routing_key = 244048
| +-- prefix_len = 6
+-- env_code = 3 (local)
Router looks up: "local:244048"

That example is real and reproducible: crc32('app-local1.example.com') % 1000000 = 244048.

Cloud and local do NOT hash the same string. Cloud hashes the full URL including the https:// scheme; local hashes the bare hostname. For the same host these are unrelated values — crc32('https://demo-tenant1.example.cloud') % 1000000 = 404909, but crc32('demo-tenant1.example.cloud') % 1000000 = 680279. Use the wrong one and the key matches no route, so every callback silently passes through to your local app.

Computing the hash

Standard CRC-32 (IEEE 802.3 / zlib / PKZIP: reflected polynomial 0xEDB88320, init 0xFFFFFFFF, final XOR 0xFFFFFFFF), taken as an unsigned 32-bit value, then % 1000000. The string is hashed byte-for-byte and is case-sensitive — nothing is lowercased on either side, so use lowercase hostnames everywhere.

LanguageExpression
PHPsprintf('%u', crc32($s)) % 1000000 — never bare crc32(), it is signed on 32-bit builds and yields keys like cloud:-723248
Pythonzlib.crc32(s.encode()) % 1000000
Gocrc32.ChecksumIEEE([]byte(s)) % 1000000
Nodezlib.crc32(Buffer.from(s)) % 1000000 (Node ≥ 20.15)

Check your implementation against the router itself — this is the authoritative answer:

sudo /usr/local/openresty/bin/resty -e 'print(ngx.crc32_short("app-local1.example.com") % 1000000)'# 244048

The route keys in the shipped Docker demo (seed/*.json) are arbitrary illustrative values, not real hashes of the demo hostnames. The router treats a route key as an opaque lookup string, so the demo works regardless — but do not reverse-engineer the hash rule from them. Use the resty command above.


Integrating your application (required)

This repository contains only the router. Nothing here generates transaction IDs. You must change your own application so that the reference it sends to the PSP is a composite ID, and confirm the PSP echoes that value back in its callback body at a path you configure on /admin/psp-rules. Without this, the router can never route anything.

The algorithm

Evaluate the branches in this order — the first match wins:

function generate_transaction_reference():
if this instance is a cloud / multi-tenant deployment:
env_code = 2
routing_key = crc32_unsigned("https://" + host_as_registered) % 1000000
else if this instance has its own database (a developer machine):
env_code = 3
routing_key = crc32_unsigned(bare_hostname) % 1000000
else: # shared staging database — the default
env_code = 1
routing_key = the integer you registered for this instance on /admin/routes
key_str = str(routing_key) # or zero-pad to a fixed width
prefix_len = len(key_str) # MUST match the width actually written
suffix = random_digits() # keep the total <= 14 characters
return str(env_code) + str(prefix_len) + key_str + suffix

How your app decides which branch it is in is entirely up to you — a config flag, an environment variable, whatever you already have. The original deployment used an env var it called APP_ROUTING_ENV with values stage / local. No code in this router reads that variable; the name is yours to choose.

Reference implementation (PHP)

publicfunctiongenerateTransactionReference(): string
{
if (config('routing.tier') === 'cloud') {
$envCode = 2;
$key = sprintf('%u', crc32('https://' . config('app.host'))) % 1000000;
} elseif (config('routing.tier') === 'local') {
$envCode = 3;
$key = sprintf('%u', crc32(parse_url(config('app.url'), PHP_URL_HOST))) % 1000000;
} else {
$envCode = 1;
$key = (int) config('routing.customer_id'); // registered on /admin/routes
}
$keyStr = (string) $key; // variable-width strategy$suffix = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
return$envCode . strlen($keyStr) . $keyStr . $suffix; // <= 14 chars
}

Keeping both sides in sync

Whatever your app emits must already be registered in the admin UI, or the lookup fails and the callback passes through:

env_codeTypeRegister atResulting keyStored in
1stage/admin/routes (type Stage)stage:<your integer>routes.json
2cloud/admin (Cloud Routes)cloud:<hash>cloud_routes.json
3local/admin/routes (type Local)local:<hash>routes.json

Any other env_code (0, 49) is rejected and the request passes through.

Use the Request Tester to confirm the round trip before touching a real PSP: paste a callback URI and body containing an ID your app generated, and the dry run shows each decode step and the upstream it resolves to.


Managing Routes

All routes are managed through the Admin Web UI — no shell scripts or manual JSON editing needed.

Admin UI Access

Every write requires Basic Auth. Read-only views are open, so the pages render for anyone who can reach the port, but nothing can be changed without credentials.

PageURLAuthPurpose
Cloud Routeshttp://<server>:8081/adminRead open, writes + reload authedManage cloud environment routes
Stage / Local Routeshttp://<server>:8081/admin/routesRead open, writes authedManage stage and local dev routes
PSP Ruleshttp://<server>:8081/admin/psp-rulesRead open, writes authedManage PSP name → body path mappings
Settingshttp://<server>:8081/admin/settingsRead open, writes authedDNS resolver, URI patterns, hosts path, cloud upstream, maintenance mode
Request Testerhttp://<server>:8081/admin/testerDry run open, live send authedDry-run routing simulation + send live requests
Healthhttp://<server>:8081/admin/healthOpenTCP connectivity check for all upstream hosts
Hosts Filehttp://<server>:8081/admin/hostsRead open, PUT/DELETE authedView/edit hosts file entries used by the router
Routing Logshttp://<server>:8081/admin/logsRead open, clear authedLive routing log — shows which callbacks were routed vs passthrough

Default credentials: admin / changeme. Change the password after first deployment.

Reaching the admin UI

The shipped config binds 127.0.0.1:8081, so the admin UI is not reachable over the network. Only stock nginx on the same host needs the port — the snippet connects to 127.0.0.1:8081 — and routing is unaffected by the bind.

Every URL in this README written as http://<server>:8081/... therefore means from the server itself, or through a tunnel from your machine:

ssh -L 8081:127.0.0.1:8081 user@server # then browse http://localhost:8081/admin

Keeping it closed matters: reads are open, so anyone who can reach the port can list your routes. Writes require Basic Auth, but those credentials are unsalted MD5 sent over plaintext HTTP — fine over loopback or a tunnel, weak on an open network.

If you genuinely need it network-reachable, change listen 127.0.0.1:8081; to listen 8081; in dist/conf/conf.d/psp-router.conf, re-deploy, and put a firewall in front of it (sudo ufw allow from <your-ip> to any port 8081).

Passwordless sudo installed by deploy.sh

deploy.sh writes /etc/sudoers.d/psp-router-reload:

ALL ALL=(root) NOPASSWD: /usr/local/openresty/bin/openresty -s reload

This is what lets the "reload" button in the admin UI restart OpenResty — the nginx worker runs unprivileged and cannot otherwise do it. It grants every user on the box the ability to run that one command as root, with no password. The command is fixed and takes no user input, so the exposure is narrow, but you should know it exists.

If you do not want it, delete the file and reload manually over SSH instead; nothing else depends on it. uninstall.sh removes it for you.

Credentials are stored in admin_credentials.json on the server only — not in git. Only someone with server SSH access can view or change them.

All pages include an Export All Data button that downloads a zip of all configuration (routes, PSP rules, settings). Individual pages also support export/import of their specific data.

Export All Data needs internet access from the browser viewing the admin UI. It builds the zip client-side with JSZip, loaded from https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js. On an air-gapped or firewalled machine the button does nothing. Use each page's individual Export button instead — plain JSON, no external dependency — or vendor jszip.min.js into dist/lua/psp_handlers/admin/html/js/ and point the <script> tags at it.

Changing the Admin Password

SSH into the server and run:

# 1. Generate a new hash. The Debian/Ubuntu package installs `resty` both at# this path and on PATH as /usr/bin/resty. (The OpenResty Docker image ships# no resty at all — use the md5sum form below there.)
sudo /usr/local/openresty/bin/resty -e 'print(ngx.md5("yournewpassword"))'# No resty available? The stored hash is a plain unsalted MD5 of the password:printf'%s''yournewpassword'| md5sum
# 2. Edit the credentials file
sudo nano /usr/local/openresty/nginx/lua/psp_handlers/core/admin_credentials.json
# Set: {"user":"admin","pass_hash":"<paste hash here>"}# 3. Reload OpenResty
sudo /usr/local/openresty/bin/openresty -s reload

Forgot Password Recovery

Requires server SSH access. Reset by generating a new hash and editing admin_credentials.json as above. Deploying from git will not overwrite this file — it can only be changed on the server.

Cloud Routes

Cloud Routes

Open http://<server>:8081/admin in your browser.

  • Add: Paste the cloud environment URL and click Add. The router normalizes the URL, extracts the hostname, computes the crc32 hash, and saves it.
  • Remove: Click the Remove button on any route.
  • Enable/Disable: Click the toggle to enable or disable a route without removing it.
  • Reload: Changes auto-reload immediately. If auto-reload fails, a "Reload Now" banner appears as a manual fallback.

Cloud routes live in cloud_routes.json on the server. They are not committed to git.

Stage Routes

Stage Routes

Open http://<server>:8081/admin/routes in your browser.

  • Select Stage from the dropdown.
  • Enter the Customer ID — the integer you chose to identify this instance, which your app must emit as the routing key (see Stage) — and the Target host (e.g. app-example.example.com).
  • Click Add.

Make sure the hostname resolves — add it via the Admin UI (/admin/hosts) or directly on the server:

echo"192.0.2.20 app-example.example.com"| sudo tee -a /etc/hosts

Static IP checkbox — if a route's IP is permanent and won't change, tick the Static IP checkbox on that route. This hides the hostname from the /admin/hosts page so it doesn't clutter the list. The checkbox can be toggled at any time and is preserved through export/import. Routing behavior is unchanged — the hostname still resolves via /etc/hosts.

Local Dev Routes

On the same page (/admin/routes):

  • Select Local from the dropdown.
  • Enter the Hostname (must match parse_url(APP_URL, PHP_URL_HOST) from the dev's .env, e.g. app-example.example.com).
  • Click Add. The crc32 hash is computed automatically.

Make sure the hostname resolves — add it via the Admin UI (/admin/hosts) or in /etc/hosts on the server.

Backup and Restore

Both admin pages have Export and Import buttons:

  • Export downloads the current routes as a JSON file (timestamped).
  • Import replaces all routes from an uploaded JSON file. Validates the format before applying.

Duplicate Protection

The admin UI prevents duplicate entries — both by key and by hostname. If a route already exists, you'll get a clear error message.


Adding a New PSP

PSP Rules

All PSP management is done via the Admin UI at /admin/psp-rules. No code changes or deployments needed.

1. Add the PSP rule — open /admin/psp-rules and add a new entry:

  • PSP name — must match the name extracted from the URI (e.g. acmepay, banklink)
  • Formatjson or xml (determines how the request body is parsed)
  • Paths — dot-separated paths to look for the composite ID in the parsed body. Tries each path in order, uses the first one that has a value.

Path examples:

Body formatBodyPathExtracts
JSON{"ref": "123"}ref123
JSON{"data":{"transaction":{"customerId":"123"}}}data.transaction.customerId123
XML<notification><ref>123</ref></notification>notification.ref123
XML<payment orderId="123">...</payment>payment.@orderId123 (attribute)

2. Check the URI pattern. The PSP name is extracted from the URI. Default patterns are managed via /admin/settings. These are already handled:

/api/0.1/user/transfer/deposit/confirm/{psp_name}
/api/0.1/user/transfer/deposit/return/{psp_name}
/api/0.1/transfer/{psp_name}/notification
/api/1.0/redirect/{psp_name}
/api/1.0/notification/{psp_name}

If your PSP uses a different URI pattern, add it via /admin/settings → URI Patterns.

3. Make sure nothing else already handles that callback path. If you have another nginx location block that statically proxies this PSP's callback URL, remove it — an earlier-matching location means the router never sees the request. See Location matching order.


Settings

Settings

Open /admin/settings to manage global configuration:

  • Maintenance Mode — instantly pause all routing. Requests passthrough to default Nginx without any processing. No reload needed. Toggle on/off with one click.

    Not durable. The flag lives in an in-memory shared dictionary. It survives a reload (openresty -s reload) but is lost on a full restart or a server reboot, at which point routing silently resumes. Re-check after any restart, and for a long-lived pause delete the include line from your nginx server block instead.

  • Default Upstream — removed. When no route matches, the request passes through to the local Laravel automatically.

  • DNS Resolver — DNS server IP used by Nginx to resolve cloud route hostnames. Required when using cloud routes. Takes effect after reload.

  • Hosts File Path — path to the hosts file used for hostname → IP resolution. Default is /etc/hosts. Useful when your environment uses a custom location.

  • Cloud Upstream — how cloud routes are rewritten before proxying: hostname prefix, scheme, and URI prefix. Deployment-specific; see the table below. Takes effect after reload.

  • Stage / Local Upstream — scheme and optional port used for stage and local routes once their hostname is resolved to an IP. Defaults to https:// on the implicit port 443. Takes effect after reload.

  • URI Patterns — Lua patterns used to extract the PSP name from the request URI. Each must contain one capture group. Order matters — first match wins.

The Cloud Upstream card holds the three keys that control how cloud routes are rewritten. They are stored in settings.json, which deploy.sh never overwrites, so your values persist across deploys:

KeyDefaultMeaning
cloud_host_prefixapi-Prepended to a cloud route's hostname
cloud_schemehttp://Scheme used for the cloud upstream hop
cloud_uri_prefix/appPrepended to the request URI for cloud routes
upstream_schemehttps://Scheme for stage/local upstreams
upstream_port(empty)Explicit port for stage/local; empty = the scheme's default

Leave a prefix or port field empty to prepend nothing — an empty value there is stored and honoured, it does not fall back to the default. Schemes are the exception: an empty cloud_scheme / upstream_scheme is rejected with a 400, because proxy_pass without a scheme is a hard 500 on every routed request. The card shows a live preview of the resulting upstream URL as you type. Changes take effect after a reload:

sudo /usr/local/openresty/bin/openresty -s reload

See Cloud (env_code = 2) for the full explanation. You can also edit settings.json directly on the server if you prefer; the Settings page preserves any keys it does not manage.

resolver.conf is also excluded from deploy. The DNS resolver you set here is written to conf/resolver.conf, which deploy.sh leaves alone — so an internal resolver survives re-deploys. It is seeded from the repo copy only on first install.

TLS to stage/local upstreams

Stage and local hostnames are resolved to an IP via the hosts file, and the proxy target is that bare IP. Two consequences worth knowing:

  • SNI is not sent. Nginx defaults to proxy_ssl_server_name off, so an upstream that selects its TLS vhost by SNI will serve its default certificate. If you need SNI, add to dist/conf/conf.d/psp-router.conf in the location ~ ^/api/ block and re-deploy:

    proxy_ssl_server_name on;proxy_ssl_name$upstream_host;
  • Certificate verification is off (nginx default), so self-signed staging certs work without extra configuration. Enable proxy_ssl_verify on; with a trust store if you need it.

For plain-HTTP upstreams set Scheme to http:// in Stage / Local Upstream — no code change needed.


Request Tester

Request Tester

Open /admin/tester to test routing without affecting live traffic:

  • Dry-run — simulates the full routing pipeline (PSP extraction → body parsing → composite ID decoding → route lookup → IP resolution) and shows the result step-by-step without sending any request.
  • Live request — sends a real HTTP request through the full router pipeline (handler.lua → proxy_pass → target upstream) and shows the actual response status, headers, and body from the target server.

Useful for verifying that a new PSP rule or route is configured correctly before going live.


Health Checks

Health Checks

Open /admin/health to check TCP connectivity to all configured upstream hosts. Shows which servers are reachable and which are not.


Hosts File Editor

Hosts File Editor

Open /admin/hosts to view and edit the hosts file entries used by the router. The router uses /etc/hosts (or the path configured in Settings) to resolve hostnames to IPs.

  • View all current entries
  • Add, edit, or remove entries
  • View raw file content

Note: Routes with the Static IP checkbox enabled on the /admin/routes page are excluded from this list, since their IPs don't need managing.


Logging and Debugging

Routing Logs

Routing logs are visible in the Admin UI at /admin/logs — each request shows whether it was routed (green) or passed through, with PSP name, route key, and target. For lower-level Nginx logs, check /usr/local/openresty/nginx/logs/error.log.

View logs:

# Last 50 lines
sudo tail -50 /usr/local/openresty/nginx/logs/error.log
# Follow in real time
sudo tail -f /usr/local/openresty/nginx/logs/error.log
# Filter by PSP
sudo grep "acmepay" /usr/local/openresty/nginx/logs/error.log

What gets logged:

LevelMessageMeaning
NOTICEParsed psp=acmepay env=stage key=126 raw_id=13126999Successfully decoded the composite ID
NOTICERouting psp=acmepay stage:126 → hostname (IP)Successfully routing to target
NOTICE/etc/hosts changed → reloadingAuto-reloaded hosts file
WARNBad request body: ...Could not read or parse JSON body
WARNFailed to parse routing key: ...Composite ID could not be decoded
WARNUnknown env_code=9 raw_id=...Unrecognized environment code
ERRNo upstream for stage:999Route key has no matching entry
ERRCannot resolve hostname for ...Hostname not in /etc/hosts

Silent passthrough (no log at all): the request URI matched none of the URI patterns in Settings. This is by design — most /api/ traffic is not meant for this router, and logging it would flood the 500-entry buffer.

It is also exactly what you see when no URI patterns are configured. If nothing is being logged, check /admin/settings → URI Patterns before anything else — see Step 5 detail.

An unknown PSP is not silent: if the URI matches a pattern but the extracted PSP name has no entry in PSP Rules, it is logged as a passthrough with a reason.

Health check:

curl http://127.0.0.1:8081/health
# {"status":"ok","service":"psp-router"}

Important: The default nginx log level only shows error level. To see NOTICE and WARN logs, add this line above the events { } block of dist/conf/nginx.conf in the repo, then re-deploy:

error_log logs/error.log notice;
sudo bash deploy.sh

Do not edit /usr/local/openresty/nginx/conf/nginx.conf directly. deploy.sh rsyncs the whole of dist/conf/ over it on every run, so a direct edit is silently lost the next time you deploy — which the Quick Start tells you to do after every git pull. resolver.conf is the exception: it is excluded from the rsync and seeded only when absent, so a DNS resolver set through /admin/settingsdoes survive re-deploys.

If you already run OpenResty for something else, note that deploy.sh overwrites its nginx.conf too. As well as the five zones, psp-router.conf references the $psp_forwarded_proto map defined in dist/conf/nginx.confOpenResty refuses to start without it (unknown "psp_forwarded_proto" variable), so copy the map block across as well. The router needs the five lua_shared_dict zones defined in dist/conf/nginx.conf (host_ip, host_meta, route_lock, psp_state, routing_log) — merge them into your existing config rather than deploying over it.


Local Testing with Docker

For testing on your dev machine (not the server). setupcreates the container for you — you do not need to have one already.

Requires: a running Docker daemon.

First-time setup:

bash docker-test.sh setup # creates container, installs OpenResty, deploys code
bash docker-test.sh test# sends test requests to verify

After code changes:

bash docker-test.sh deploy # re-deploys code and reloads OpenResty
bash docker-test.sh test# verify

Full command list:

CommandEffect
setupCreate the container, install OpenResty, deploy code
deployRe-deploy code and reload OpenResty
testSend test requests
start / stop / restartContainer lifecycle
statusShow container and OpenResty state
logsTail the OpenResty error log
shellOpen a shell inside the container
destroyRemove the container

Run bash docker-test.sh without arguments to see the same list.

This exercises the OpenResty side only. It does not rehearse the stock-Nginx integration from Step 3 — that part you have to verify on a real server with nginx -T.

One-command demo with Docker Compose

The repo also ships a self-contained demo image with pre-populated example data — eight stage/local routes, four cloud routes, ten PSP rules and a seeded routing log — so you can click through the whole admin UI without configuring anything:

docker compose up -d --build
# then open http://localhost:8081/admin (admin / changeme)

docker compose down removes it. The seed data lives in seed/ and is baked in at build time; edit those files and rebuild to try your own.

The image binds 0.0.0.0:8081 inside the container (see the sed in the Dockerfile) because the shipped server config binds loopback only, which docker run -p cannot reach. That override is for the demo — it does not affect a real deployment.

If port 8081 is already in use on your machine, change the mapping in docker-compose.yml (e.g. "8091:8081").


Disabling and Uninstalling

Temporarily disable (keep everything installed)

Remove or comment out the include line in your App nginx config:

# include /etc/nginx/snippets/psp-router.conf;

Then reload:

sudo nginx -s reload

OpenResty stays running on port 8081 but gets no traffic. Add the line back to re-enable.

Commenting out is fine for disabling, but blocks uninstall.sh. Its pre-flight check is grep -rl "psp-router.conf" /etc/nginx/, which matches a commented line just as happily as a live one. To uninstall, delete the include line rather than commenting it, then sudo nginx -t && sudo nginx -s reload.

Full uninstall

# First: remove the include line from App nginx config and reload nginx# Then: full uninstall (removes OpenResty package too)
sudo bash uninstall.sh
# Or: remove code only, keep OpenResty installed
sudo bash uninstall.sh --keep

The script checks that you've removed the include line first and refuses to run if it's still there.


File Reference

Repository layout

lua/
dist/ # Mirrors /usr/local/openresty/nginx/ on server
conf/
nginx.conf # OpenResty main config
resolver.conf # DNS resolver directive (managed by admin Settings)
conf.d/
psp-router.conf # OpenResty server block (port 8081)
lua/psp_handlers/
handler.lua # Main entry point (access_by_lua_file)
config.lua # Env type mappings (env_code → stage/cloud/local)
admin.lua # Cloud admin entry point (thin wrapper)
admin_routes.lua # Stage/local admin entry point (thin wrapper)
admin_psp.lua # PSP rules entry point
admin_settings.lua # Settings entry point
admin_tester.lua # Request tester entry point
admin_health.lua # Health check entry point
admin_hosts.lua # Hosts file editor entry point
admin_logs.lua # Routing logs entry point
admin/
shared.lua # Shared: lock, file I/O, auth, hash
cloud_api.lua # Cloud routes REST API
routes_api.lua # Stage/local routes REST API
psp_api.lua # PSP rules REST API
settings_api.lua # Settings + maintenance mode API
tester_api.lua # Request tester (dry-run + live) API
health_api.lua # Upstream health check API
hosts_api.lua # Hosts file viewer/editor API
logs_api.lua # Routing logs API (circular buffer in shared dict)
html/
cloud.html # Cloud admin UI
routes.html # Stage/local admin UI
psp.html # PSP rules UI
settings.html # Settings UI (upstream, DNS resolver, patterns, hosts path, maintenance)
tester.html # Request tester UI
health.html # Health check UI
hosts.html # Hosts file editor UI
logs.html # Routing logs UI
js/shared.js # Shared JS (auth, nav, theme toggle, export)
css/admin.css # Shared CSS (dark/light glassmorphism theme)
core/
utils.lua # JSON parsing, composite ID decoding
routes.lua # Route loading logic (reads JSON files)
routes.json # Stage/local route data — SAMPLE ONLY, never deployed
cloud_routes.json # Cloud route data (server only, not in git)
psp_rules.lua # PSP rules loader (reads/writes JSON)
settings.lua # Settings loader (reads/writes settings.json)
settings.json # Runtime settings (upstream, DNS resolver, patterns, hosts path)
hosts_resolver.lua # /etc/hosts -> IP cache with auto-reload
snippets/
psp-router-proxy.conf # Deployed to /etc/nginx/snippets/psp-router.conf
setup.sh # Install OpenResty (one-time)
deploy.sh # Deploy code to server
test.sh # Run tests on server
uninstall.sh # Full removal
docker-test.sh # Local dev testing via Docker

What gets deployed where

Source fileDeployed toPurpose
dist/conf/nginx.conf/usr/local/openresty/nginx/conf/nginx.confOpenResty main config
dist/conf/resolver.confseeded to /usr/local/openresty/nginx/conf/resolver.confonly if absentDNS resolver (managed by admin Settings; excluded from the rsync)
dist/conf/conf.d/psp-router.conf/usr/local/openresty/nginx/conf/conf.d/psp-router.confServer block (port 8081)
dist/lua/psp_handlers//usr/local/openresty/nginx/lua/psp_handlers/All Lua code
dist/snippets/psp-router-proxy.conf/etc/nginx/snippets/psp-router.confSnippet for stock Nginx

Runtime data files — never overwritten by a deploy. Five data files are --excluded from the rsync, so a deploy never overwrites them once they exist on the server. The one time the repo copy is used is first install, when deploy.sh seeds a missing file (see the "Seeded from" column) — after that your repo edits stay in the repo:

FileSeeded on first deploy fromManaged via
routes.json{}/admin/routes
cloud_routes.json{}/admin
psp_rules.json{}/admin/psp-rules
settings.jsonthe repo copy (URI patterns; upstream keys fall back to built-in defaults)/admin/settings
admin_credentials.jsonadmin / changemedeploy.sh comment shows how to rehash

resolver.conf is excluded too, so a DNS resolver set in the admin UI survives re-deploys.

Any copy of these files in the repo is sample/seed data only — it is never live configuration. The live copies exist only under /usr/local/openresty/nginx/lua/psp_handlers/core/ on the server.

Key files to edit

What you want to doWhere
Add/configure a PSPAdmin UI: /admin/psp-rules
Add a stage or local routeAdmin UI: /admin/routes
Add a cloud routeAdmin UI: /admin
No route matches a callbackRequest passes through to local Laravel automatically
Change DNS resolverAdmin UI: /admin/settings
Change URI patternsAdmin UI: /admin/settings
Change hosts file pathAdmin UI: /admin/settings
Toggle maintenance modeAdmin UI: /admin/settings
Test routing (dry-run)Admin UI: /admin/tester
Check upstream connectivityAdmin UI: /admin/health
Edit hosts file entriesAdmin UI: /admin/hosts
View routing logsAdmin UI: /admin/logs
Change admin credentialsadmin_credentials.json on the server (SSH required)

Troubleshooting

ProblemLikely causeFix
Server unreachable (ERR_CONNECTION_REFUSED on server IP)OpenResty is stopped and disabled (won't auto-start on reboot)See Server Unreachable below
curl: (7) Failed to connect to port 8081OpenResty not runningsudo bash deploy.sh (starts it automatically)
Nothing routes at all and /admin/logs is completely emptyURI pattern list is empty, or no pattern matches your callback URLs. Non-matching requests are deliberately not logged, so this looks identical to "no traffic"Add patterns at /admin/settings — see Step 5 detail
Health check works but no routingInclude line not in nginx config, or an earlier-matching /api/ location winsAdd the include; then check precedence with sudo nginx -T | grep -n 'location' — see Location matching order
Every /api/ URL returns 502, including non-PSP onesOpenResty is down. The snippet proxies all/api/ traffic, and only 460 is interceptedStart it (sudo bash deploy.sh) and register it for boot with sudo systemctl enable openresty
Cloud route hangs ~5s then failsDNS resolver is still a placeholder or unreachable. Only cloud routes use itSet a reachable resolver at /admin/settings, then reload OpenResty
Callback reaches the wrong tenant's serverprefix_len does not match the key width — the router read digits of your random suffixSee the prefix_len warning in Composite ID Format
Request goes to the local app instead of routingPSP not configuredAdd the PSP via Admin UI: /admin/psp-rules
No upstream for stage:X in logsRoute key not in routesAdd the route via the admin UI
Cannot resolve hostname in logsHostname not in /etc/hostsAdd hostname to /etc/hosts on the server
Unknown env_code=X in logsComposite ID's first character is not 1, 2 or 3Check your app's ID generator — see Integrating your application
Failed to parse routing key in logsThe ID exceeded 14 digits and arrived in scientific notation, or prefix_len was wrongShorten the random suffix; see Composite ID Format
Local dev route not matchingcrc32 hash mismatch — local hashes the bare hostname, cloud hashes the full URL with schemeVerify with sudo /usr/local/openresty/bin/resty -e 'print(ngx.crc32_short("host") % 1000000)' and re-add via the admin UI
Changes not taking effectLua modules cached / auto-reload failedSee Changes Not Taking Effect below
Infinite redirect loopLoop prevention not workingCheck that psp-router-proxy.conf snippet has the X-PSP-Routed check
uninstall.sh refuses to runInclude still in nginx configRemove the include line, reload nginx, then uninstall
Hosts editor "cannot write"File not writable by workerchmod 666 /etc/hosts (Docker) or check file permissions
Hosts resolver not using custom pathSettings not savedCheck settings.json has hosts_file_path set, reload OpenResty

Server Unreachable (ERR_CONNECTION_REFUSED)

If the server IP (e.g. 192.0.2.30) suddenly stops responding with ERR_CONNECTION_REFUSED, it usually means OpenResty has stopped. This can happen after a server reboot if the OpenResty service is not enabled for auto-start.

Diagnose:

# SSH into the server, then:# Check if Nginx (stock) is running
sudo systemctl status nginx
# Check if OpenResty is running
sudo systemctl status openresty

If you see Active: inactive (dead) and Loaded: ... disabled for OpenResty, that's the problem.

Fix:

# Start OpenResty
sudo systemctl start openresty
# Enable auto-start on reboot so this doesn't happen again
sudo systemctl enable openresty
# Verify it's running
sudo systemctl status openresty

If OpenResty fails to start, check the config:

sudo openresty -t

Still not reachable? Verify what ports are actually listening:

sudo ss -tlnp | grep -E ':(80|443|8081)\b'

You should see entries for ports 80/443 (stock Nginx) and 8081 (OpenResty). If port 8081 is missing, OpenResty didn't start properly — check logs:

sudo journalctl -u openresty --since "30 min ago" --no-pager

Other possible causes:

  • Disk full — df -h (Nginx/OpenResty can't write PID files or logs)
  • Out of memory — free -m
  • Port conflict — another process grabbed port 8081

First-Time Setup on Ubuntu (e.g. stage server)

When running setup.sh on an Ubuntu server for the first time, you may encounter several issues. Here's what to expect and how to fix them.

1. Broken third-party APT repos block apt-get update

If the server has stale or expired third-party repos (e.g. Caddy, NodeSource), apt-get update will fail before OpenResty can be installed.

GPG error: https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version InRelease: EXPKEYSIG ...
E: The repository 'https://deb.nodesource.com/node_20.x jammy Release' no longer has a Release file.

Fix: Temporarily disable the broken repos, run setup, then restore them:

# Disable
sudo mv /etc/apt/sources.list.d/caddy-stable.list /etc/apt/sources.list.d/caddy-stable.list.bak
sudo mv /etc/apt/sources.list.d/nodesource.list /etc/apt/sources.list.d/nodesource.list.bak
# Run setup
sudo bash setup.sh
# Restore
sudo mv /etc/apt/sources.list.d/caddy-stable.list.bak /etc/apt/sources.list.d/caddy-stable.list
sudo mv /etc/apt/sources.list.d/nodesource.list.bak /etc/apt/sources.list.d/nodesource.list

2. OpenResty service fails to start — port 80 conflict

After installing, the OpenResty package's systemd service tries to start automatically on port 80, which conflicts with the stock Nginx already running on that port:

nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
dpkg: error processing package openresty (--configure)

This leaves dpkg in a broken state (3 not fully installed or removed).

Fix: Disable the systemd service and fix dpkg, then re-run setup:

sudo systemctl disable openresty
sudo systemctl stop openresty 2>/dev/null;true
sudo dpkg --configure -a --force-confdef
sudo bash setup.sh

This is expected on servers where Nginx already occupies port 80: the OpenResty package's stock config binds port 80 and collides with it, so setup.sh disables the unit to get through installation.

That is a temporary state, not the end state. Once deploy.sh has installed this project's nginx.conf — which listens only on 8081 — the collision is gone and you should re-enable the unit so OpenResty survives a reboot:

sudo systemctl enable openresty # add --now only if no master is already running

Without it, OpenResty stays down after a reboot and every/api/ request on the vhost returns 502, because the snippet proxies all of /api/ to 127.0.0.1:8081 and only status 460 is intercepted. This is Quick Start step 6.

3. GPG key overwrite prompt

If setup.sh ran partially before, you may see:

File '/usr/share/keyrings/openresty.gpg' exists. Overwrite? (y/N)

Type y and press Enter. This is safe — it's just refreshing the GPG key from a previous attempt.

Changes Not Taking Effect After Adding PSP/Route/Hosts Entry

PSP rules (psp_rules.lua) and routes (routes.lua) are cached in memory when OpenResty workers start. The admin UI writes changes to the JSON files on disk, but the handler keeps using the cached copy until OpenResty is reloaded.

Why the dry-run tester works but live requests don't: The request tester reads JSON files directly from disk (always fresh), while the live handler uses the in-memory cached modules. This means the tester will show "all green" even though live requests still use stale data.

How auto-reload works: The admin UI automatically triggers sudo openresty -s reload after every config change. This creates fresh worker processes that re-read the JSON files. If auto-reload succeeds, changes take effect immediately with no manual action needed.

If auto-reload fails, a yellow banner appears at the top of the admin page with the manual command. Common reasons:

CauseFix
Sudoers entry missingRe-run sudo bash deploy.sh — it installs /etc/sudoers.d/psp-router-reload
First deploy before this fixRun manually: sudo /usr/local/openresty/bin/openresty -s reload
OpenResty config errorCheck: sudo /usr/local/openresty/bin/openresty -t

Manual reload (always works):

sudo /usr/local/openresty/bin/openresty -s reload

Configurable Hosts File Path

The path to the hosts file used for hostname → IP resolution is configurable via the Settings page. Default is /etc/hosts. This is useful when:

  • Your environment uses a custom hosts file location
  • You want to use a separate file to avoid modifying the system hosts file

Both the hostname resolver and the hosts editor read this setting from settings.json.

Docker: Hosts File Permissions

When running in Docker, the hosts file must be writable by the nginx worker user (nobody). Docker bind-mounts /etc/hosts with root:root rw-r--r-- permissions by default, which blocks writes from the worker.

Fix: Add to your Dockerfile or entrypoint script:

chmod 666 /etc/hosts

Without this, the hosts editor page will fail to save changes.

About

PSP callback router built with OpenResty/Lua

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages