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.
- Prerequisites
- Quick Start
- How It Works
- Environment Types Explained
- Integrating your application (required)
- Managing Routes
- Adding a New PSP
- Settings
- Request Tester
- Health Checks
- Hosts File Editor
- Logging and Debugging
- Local Testing with Docker
- Disabling and Uninstalling
- File Reference
- Troubleshooting
Debian/Ubuntu server with root/sudo access, and
rsyncinstalled (used bydeploy.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 oneincludeline.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/hostsentries for stage/local upstream hostnames (managed from the admin UI)
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.shAbout 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.
| Exit | Meaning |
|---|---|
| 0 | Everything checked, including end-to-end routing |
| 1 | A check failed — the install is broken |
| 2 | Nothing 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.shThis 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'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_errorsapplies to this whole location, so anyerror_pagedirectives inherited from yourserver/httplevel 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 thelocation ~ ^/api/regex indist/snippets/psp-router-proxy.conf— e.g.location ~ ^/api/[0-9.]+/(notification|redirect)/— and re-rundeploy.sh.
"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 staticproxy_passrules 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'.
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 copy — deploy.sh overwrites
/etc/nginx/snippets/psp-router.conf on every run. Then re-run sudo bash deploy.sh.
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.
/healthstill 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".
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.
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.
[env_code][prefix_len][routing_key][random_remaining_digits]
| Position | Field | Width | Meaning |
|---|---|---|---|
| 1 | env_code | exactly 1 char | 1 = stage, 2 = cloud, 3 = local. Anything else is rejected → passthrough |
| 2 | prefix_len | exactly 1 char | The number of characters routing_key occupies — not its value. 1–9 |
3 … 2+prefix_len | routing_key | prefix_len chars | Decimal digits; leading zeros allowed |
| remainder | random suffix | see limit below | Whatever makes the reference unique |
Three constraints that are easy to miss and fail silently:
prefix_lenmust equal the character width of the key as written. The router slices exactlyprefix_lencharacters and never checks that the slice ends where the key ends.crc32 % 1000000is under 100000 roughly 10% of the time — for examplecrc32('https://demo-tenant2.example.cloud') % 1000000=66708, five digits. Emitprefix_len = 6for that key and the router reads66708plus 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 = 6always). Both work, because the router runstonumber()on the slice — so006708and6708both resolve tocloud:6708. Becauseprefix_lenis 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.asprefix_len, fails, and every callback passes through silently with only a WARN inerror.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_idcolumn 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:
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"
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:
| Key | Default | Meaning |
|---|---|---|
cloud_host_prefix | api- | Prepended to the registered hostname |
cloud_scheme | http:// | Scheme for the upstream hop |
cloud_uri_prefix | /app | Prepended 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.
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, butcrc32('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.
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.
| Language | Expression |
|---|---|
| PHP | sprintf('%u', crc32($s)) % 1000000 — never bare crc32(), it is signed on 32-bit builds and yields keys like cloud:-723248 |
| Python | zlib.crc32(s.encode()) % 1000000 |
| Go | crc32.ChecksumIEEE([]byte(s)) % 1000000 |
| Node | zlib.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)'# 244048The 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 therestycommand above.
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.
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.
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
}Whatever your app emits must already be registered in the admin UI, or the lookup fails and the callback passes through:
| env_code | Type | Register at | Resulting key | Stored in |
|---|---|---|---|---|
| 1 | stage | /admin/routes (type Stage) | stage:<your integer> | routes.json |
| 2 | cloud | /admin (Cloud Routes) | cloud:<hash> | cloud_routes.json |
| 3 | local | /admin/routes (type Local) | local:<hash> | routes.json |
Any other env_code (0, 4–9) 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.
All routes are managed through the Admin Web UI — no shell scripts or manual JSON editing needed.
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.
| Page | URL | Auth | Purpose |
|---|---|---|---|
| Cloud Routes | http://<server>:8081/admin | Read open, writes + reload authed | Manage cloud environment routes |
| Stage / Local Routes | http://<server>:8081/admin/routes | Read open, writes authed | Manage stage and local dev routes |
| PSP Rules | http://<server>:8081/admin/psp-rules | Read open, writes authed | Manage PSP name → body path mappings |
| Settings | http://<server>:8081/admin/settings | Read open, writes authed | DNS resolver, URI patterns, hosts path, cloud upstream, maintenance mode |
| Request Tester | http://<server>:8081/admin/tester | Dry run open, live send authed | Dry-run routing simulation + send live requests |
| Health | http://<server>:8081/admin/health | Open | TCP connectivity check for all upstream hosts |
| Hosts File | http://<server>:8081/admin/hosts | Read open, PUT/DELETE authed | View/edit hosts file entries used by the router |
| Routing Logs | http://<server>:8081/admin/logs | Read open, clear authed | Live routing log — shows which callbacks were routed vs passthrough |
Default credentials: admin / changeme. Change the password after first deployment.
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 to127.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/adminKeeping 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;tolisten 8081;indist/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).
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 vendorjszip.min.jsintodist/lua/psp_handlers/admin/html/js/and point the<script>tags at it.
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 reloadRequires 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.
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.
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/hostsStatic 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.
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.
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.
The admin UI prevents duplicate entries — both by key and by hostname. If a route already exists, you'll get a clear error message.
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) - Format —
jsonorxml(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 format | Body | Path | Extracts |
|---|---|---|---|
| JSON | {"ref": "123"} | ref | 123 |
| JSON | {"data":{"transaction":{"customerId":"123"}}} | data.transaction.customerId | 123 |
| XML | <notification><ref>123</ref></notification> | notification.ref | 123 |
| XML | <payment orderId="123">...</payment> | payment.@orderId | 123 (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.
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 theincludeline 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:
| Key | Default | Meaning |
|---|---|---|
cloud_host_prefix | api- | Prepended to a cloud route's hostname |
cloud_scheme | http:// | Scheme used for the cloud upstream hop |
cloud_uri_prefix | /app | Prepended to the request URI for cloud routes |
upstream_scheme | https:// | 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 reloadSee 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.confis also excluded from deploy. The DNS resolver you set here is written toconf/resolver.conf, whichdeploy.shleaves alone — so an internal resolver survives re-deploys. It is seeded from the repo copy only on first install.
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 todist/conf/conf.d/psp-router.confin thelocation ~ ^/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.
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.
Open /admin/health to check TCP connectivity to all configured upstream hosts. Shows which servers are reachable and which are not.
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.
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.logWhat gets logged:
| Level | Message | Meaning |
|---|---|---|
| NOTICE | Parsed psp=acmepay env=stage key=126 raw_id=13126999 | Successfully decoded the composite ID |
| NOTICE | Routing psp=acmepay stage:126 → hostname (IP) | Successfully routing to target |
| NOTICE | /etc/hosts changed → reloading | Auto-reloaded hosts file |
| WARN | Bad request body: ... | Could not read or parse JSON body |
| WARN | Failed to parse routing key: ... | Composite ID could not be decoded |
| WARN | Unknown env_code=9 raw_id=... | Unrecognized environment code |
| ERR | No upstream for stage:999 | Route key has no matching entry |
| ERR | Cannot 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.shDo not edit
/usr/local/openresty/nginx/conf/nginx.confdirectly.deploy.shrsyncs the whole ofdist/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 everygit pull.resolver.confis 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.shoverwrites itsnginx.conftoo. As well as the five zones,psp-router.confreferences the$psp_forwarded_protomap defined indist/conf/nginx.conf— OpenResty refuses to start without it (unknown "psp_forwarded_proto" variable), so copy themapblock across as well. The router needs the fivelua_shared_dictzones defined indist/conf/nginx.conf(host_ip,host_meta,route_lock,psp_state,routing_log) — merge them into your existing config rather than deploying over it.
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 verifyAfter code changes:
bash docker-test.sh deploy # re-deploys code and reloads OpenResty
bash docker-test.sh test# verifyFull command list:
| Command | Effect |
|---|---|
setup | Create the container, install OpenResty, deploy code |
deploy | Re-deploy code and reload OpenResty |
test | Send test requests |
start / stop / restart | Container lifecycle |
status | Show container and OpenResty state |
logs | Tail the OpenResty error log |
shell | Open a shell inside the container |
destroy | Remove 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.
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:8081inside the container (see thesedin theDockerfile) because the shipped server config binds loopback only, whichdocker run -pcannot 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").
Remove or comment out the include line in your App nginx config:
# include /etc/nginx/snippets/psp-router.conf;Then reload:
sudo nginx -s reloadOpenResty 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 isgrep -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, thensudo nginx -t && sudo nginx -s reload.
# 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 --keepThe script checks that you've removed the include line first and refuses to run if it's still there.
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
| Source file | Deployed to | Purpose |
|---|---|---|
dist/conf/nginx.conf | /usr/local/openresty/nginx/conf/nginx.conf | OpenResty main config |
dist/conf/resolver.conf | seeded to /usr/local/openresty/nginx/conf/resolver.confonly if absent | DNS 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.conf | Server 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.conf | Snippet 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:
| File | Seeded on first deploy from | Managed via |
|---|---|---|
routes.json | {} | /admin/routes |
cloud_routes.json | {} | /admin |
psp_rules.json | {} | /admin/psp-rules |
settings.json | the repo copy (URI patterns; upstream keys fall back to built-in defaults) | /admin/settings |
admin_credentials.json | admin / changeme | deploy.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.
| What you want to do | Where |
|---|---|
| Add/configure a PSP | Admin UI: /admin/psp-rules |
| Add a stage or local route | Admin UI: /admin/routes |
| Add a cloud route | Admin UI: /admin |
| No route matches a callback | Request passes through to local Laravel automatically |
| Change DNS resolver | Admin UI: /admin/settings |
| Change URI patterns | Admin UI: /admin/settings |
| Change hosts file path | Admin UI: /admin/settings |
| Toggle maintenance mode | Admin UI: /admin/settings |
| Test routing (dry-run) | Admin UI: /admin/tester |
| Check upstream connectivity | Admin UI: /admin/health |
| Edit hosts file entries | Admin UI: /admin/hosts |
| View routing logs | Admin UI: /admin/logs |
| Change admin credentials | admin_credentials.json on the server (SSH required) |
| Problem | Likely cause | Fix |
|---|---|---|
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 8081 | OpenResty not running | sudo bash deploy.sh (starts it automatically) |
Nothing routes at all and /admin/logs is completely empty | URI 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 routing | Include line not in nginx config, or an earlier-matching /api/ location wins | Add the include; then check precedence with sudo nginx -T | grep -n 'location' — see Location matching order |
Every /api/ URL returns 502, including non-PSP ones | OpenResty is down. The snippet proxies all/api/ traffic, and only 460 is intercepted | Start it (sudo bash deploy.sh) and register it for boot with sudo systemctl enable openresty |
| Cloud route hangs ~5s then fails | DNS resolver is still a placeholder or unreachable. Only cloud routes use it | Set a reachable resolver at /admin/settings, then reload OpenResty |
| Callback reaches the wrong tenant's server | prefix_len does not match the key width — the router read digits of your random suffix | See the prefix_len warning in Composite ID Format |
| Request goes to the local app instead of routing | PSP not configured | Add the PSP via Admin UI: /admin/psp-rules |
No upstream for stage:X in logs | Route key not in routes | Add the route via the admin UI |
Cannot resolve hostname in logs | Hostname not in /etc/hosts | Add hostname to /etc/hosts on the server |
Unknown env_code=X in logs | Composite ID's first character is not 1, 2 or 3 | Check your app's ID generator — see Integrating your application |
Failed to parse routing key in logs | The ID exceeded 14 digits and arrived in scientific notation, or prefix_len was wrong | Shorten the random suffix; see Composite ID Format |
| Local dev route not matching | crc32 hash mismatch — local hashes the bare hostname, cloud hashes the full URL with scheme | Verify with sudo /usr/local/openresty/bin/resty -e 'print(ngx.crc32_short("host") % 1000000)' and re-add via the admin UI |
| Changes not taking effect | Lua modules cached / auto-reload failed | See Changes Not Taking Effect below |
| Infinite redirect loop | Loop prevention not working | Check that psp-router-proxy.conf snippet has the X-PSP-Routed check |
uninstall.sh refuses to run | Include still in nginx config | Remove the include line, reload nginx, then uninstall |
| Hosts editor "cannot write" | File not writable by worker | chmod 666 /etc/hosts (Docker) or check file permissions |
| Hosts resolver not using custom path | Settings not saved | Check settings.json has hosts_file_path set, reload OpenResty |
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 openrestyIf 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 openrestyIf OpenResty fails to start, check the config:
sudo openresty -tStill 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-pagerOther 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
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.list2. 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.shThis 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 runningWithout 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.
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:
| Cause | Fix |
|---|---|
| Sudoers entry missing | Re-run sudo bash deploy.sh — it installs /etc/sudoers.d/psp-router-reload |
| First deploy before this fix | Run manually: sudo /usr/local/openresty/bin/openresty -s reload |
| OpenResty config error | Check: sudo /usr/local/openresty/bin/openresty -t |
Manual reload (always works):
sudo /usr/local/openresty/bin/openresty -s reloadThe 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.
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/hostsWithout this, the hosts editor page will fail to save changes.







