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.

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.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 {
    listen 443 ssl;
    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 server
    if ($http_x_psp_routed = "1") {
        rewrite ^ /index.php last;
    }

    proxy_pass http://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_page 460 = @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 both rewrite ^ /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_pass http://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]
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. 19
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_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 66708 plus 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:

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.

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.

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)'
# 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)

public function generateTransactionReference(): 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_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, 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.

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.

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 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.


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:

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 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:

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.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/settings does 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). setup creates 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:

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.

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 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.conf only 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.

Key files to edit

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)

Troubleshooting

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

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:

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 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