Notice: Article still under construction, screenshots will be added soon. The content is accurate and has been verified.
This guide shows how to set up APISIX as a reverse proxy in front of the BinaryLane API. You can deploy it in two modes:
- Basic mode - JWT-authenticated token relay. Anyone with a valid JWT gets full API access.
- Scoped mode - per-user permission scoping. Different users get different levels of API access (read-only, support operations, mapped admin actions, etc.).
Both modes use the same infrastructure. Scoped mode is an upgrade you can add after basic mode is running.
What you get: Your team members, scripts, or third-party tools authenticate with short-lived JWT tokens. APISIX validates the token and forwards requests to the BinaryLane API using your master token behind the scenes. The master token never leaves the VPS.
What you do not get: Ongoing managed support for the proxy server, APISIX, Caddy, Docker, or the operating system. This is a customer-managed deployment. BinaryLane support can provide general guidance where feasible, but OS and application administration remains your responsibility: What support options are available?
Prerequisites
- A BinaryLane API key (Getting Started with BinaryLane API). Do not put this key in the cloud-init payload; the activation script asks for it after first boot.
- A VPS running Ubuntu 24.04 -
std-1vcpuor higher (1 vCPU / 2GB RAM / 40GB SSD, from $9.80/mo) - Access to mPanel to create the VPS and provide cloud-init user data
- Optional: a DNS name pointing at the VPS for public HTTPS mode (see Choosing the Right Nameservers for Your Domain)
- Optional: a VPC, VPN, bastion, or SSH tunnel path for private IP-only mode
TABLE OF CONTENTS
- Prerequisites
- 1. Quickstart: Deploy with Cloud-Init
- 2. See It Working: What the Proxy Actually Does
- 3. Manual Setup
- 3.1 Install Docker
- 3.2 Set Up the Firewall
- 3.3 Deploy the Stack
- 3.4 Configure the Proxy (Basic Mode)
- 3.5 Upgrade to Scoped Access (Optional)
- 4. Security Notes
- 5. Troubleshooting
- 6. Next Steps / Future
1. Quickstart: Deploy with Cloud-Init
The quickest setup path is to create the VPS with a cloud-init payload, then run an activation script inside the server.
The cloud-init payload installs the proxy appliance files, Docker stack, manifests, and helper scripts. It does not contain your BinaryLane API key.
Use this method when you want the server prepared automatically and only want to enter sensitive values after first boot.
1.1 Create the VPS with the Cloud-Init Payload
In mPanel, create an Ubuntu 24.04 VPS. Open the Show cloud-init payload dropdown below, copy the full payload, and paste it into the cloud-init or user-data field.
The payload is large because it includes the Docker Compose stack, APISIX scripts, role manifests, activation script, and helper scripts. It pulls the container images during first boot, but does not start the proxy listener until you run the activation script. The generated payload must stay under BinaryLane's cloud-init user-data size limit. Review the payload before using it. If you are rebuilding the payload from a source bundle, run this local validation before copying it into mPanel:
./scripts/validate-cloud-init.sh
After the VPS finishes booting, SSH in using your key as root or a sudo-capable user.
Show cloud-init payload
#cloud-config
package_update: true
packages:
- ca-certificates
- curl
- jq
- openssl
- python3
- python3-pip
write_files:
- path: /opt/bl-proxy/caddy/Caddyfile
owner: root:root
permissions: '0644'
content: |
:80 {
reverse_proxy apisix:9080
encode gzip
log {
output file /data/access.log {
roll_size 50mb
roll_keep 5
}
}
}
- path: /opt/bl-proxy/apisix/config.yaml
owner: root:root
permissions: '0640'
content: |
deployment:
role: traditional
role_traditional:
config_provider: etcd
admin:
listen:
ip: 0.0.0.0
port: 9180
allow_admin:
- 0.0.0.0/0
admin_key:
- name: admin
key: '__ADMIN_KEY__'
role: admin
etcd:
host:
- 'http://etcd:2379'
prefix: '/apisix'
- path: /opt/bl-proxy/README-FIRST.txt
owner: root:root
permissions: '0644'
content: |
BinaryLane scoped API proxy
Cloud-init has installed the proxy files under /opt/bl-proxy.
It also pulls the Docker images, but it does not start the proxy listener.
The BinaryLane API token is not stored in this cloud-init payload.
To activate the proxy:
sudo /opt/bl-proxy/scripts/activate.sh
The activation script will ask for:
- your BinaryLane API token
- access mode: public HTTPS with a DNS name, or private/IP-only HTTP
- initial users and roles
After activation, read:
sudo cat /opt/bl-proxy/DEPLOYMENT-NOTES.txt
That file explains how to add users, generate JWTs, and call the proxy.
- path: /opt/bl-proxy/docker-compose.yml
owner: root:root
permissions: '0644'
content: |
services:
apisix:
image: apache/apisix:3.17.0-debian
container_name: apisix
# Bind to 127.0.0.1 only - Caddy is the public-facing terminator
ports:
- "127.0.0.1:9080:9080"
volumes:
- ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- ./apisix/logs:/usr/local/apisix/logs
depends_on:
- etcd
restart: unless-stopped
networks:
- apisix-net
caddy:
image: caddy:2-alpine
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
- DOMAIN=${DOMAIN:-bl-proxy.example.com}
depends_on:
- apisix
restart: unless-stopped
networks:
- apisix-net
etcd:
image: quay.io/coreos/etcd:v3.5.9
container_name: etcd
ports: []
command: >
etcd --listen-client-urls=http://0.0.0.0:2379
--advertise-client-urls=http://etcd:2379
--data-dir=/etcd-data
--enable-v2=true
volumes:
- etcd-data:/etcd-data
restart: unless-stopped
networks:
- apisix-net
volumes:
etcd-data:
caddy-data:
caddy-config:
networks:
apisix-net:
driver: bridge
- path: /opt/bl-proxy/scripts/activate.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
# First-run activation for the BinaryLane scoped API proxy appliance.
set -euo pipefail
cd "$(dirname "$0")/.."
umask 077
echo "BinaryLane scoped API proxy activation"
echo
echo "This script stores your BinaryLane API token locally in /opt/bl-proxy/.env"
echo "with root-only permissions. It is not included in cloud-init user data."
echo
read -rsp "BinaryLane API token: " BL_API_TOKEN
echo
if [ -z "$BL_API_TOKEN" ]; then
echo "API token is required." >&2
exit 1
fi
echo
echo "Choose access mode:"
echo " 1) Public HTTPS with a DNS name (recommended for internet access)"
echo " 2) Private/IP-only HTTP for VPC, VPN, bastion, or testing"
read -rp "Access mode [1]: " ACCESS_MODE
ACCESS_MODE="${ACCESS_MODE:-1}"
PUBLIC_MODE="https-domain"
DOMAIN=""
if [ "$ACCESS_MODE" = "2" ]; then
PUBLIC_MODE="private-http"
else
read -rp "DNS name for this proxy, e.g. bl-proxy.example.com: " DOMAIN
if [ -z "$DOMAIN" ]; then
echo "A DNS name is required for public HTTPS mode." >&2
exit 1
fi
fi
cat > .env <<EOF
BL_API_TOKEN=$BL_API_TOKEN
DOMAIN=$DOMAIN
PUBLIC_MODE=$PUBLIC_MODE
EOF
chmod 600 .env
if [ "$PUBLIC_MODE" = "private-http" ]; then
cat > caddy/Caddyfile <<'EOF'
:80 {
reverse_proxy apisix:9080
encode gzip
log {
output file /data/access.log {
roll_size 50mb
roll_keep 5
}
}
}
EOF
PROXY_URL="http://<vps-private-or-public-ip>"
else
cat > caddy/Caddyfile <<'EOF'
{$DOMAIN} {
reverse_proxy apisix:9080
encode gzip
log {
output file /data/access.log {
roll_size 50mb
roll_keep 5
}
}
}
EOF
PROXY_URL="https://$DOMAIN"
fi
echo
echo "Starting Docker stack..."
docker compose up -d
docker compose restart caddy >/dev/null 2>&1 || true
echo "Waiting for APISIX admin API..."
for _ in $(seq 1 60); do
if docker inspect apisix >/dev/null 2>&1; then
API_IP="$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' apisix)"
ADMIN_KEY="$(python3 - <<'PY'
import re
s = open("apisix/config.yaml").read()
print(re.search(r"key: '([^']+)'", s).group(1))
PY
)"
if curl -fsS -H "X-API-KEY: $ADMIN_KEY" "http://$API_IP:9180/apisix/admin/routes" >/dev/null 2>&1; then
break
fi
fi
sleep 2
done
mkdir -p secrets
chmod 700 secrets
reset_users=1
if [ -s manifest/users.json ]; then
user_state="$(python3 - <<'PY'
import json
from pathlib import Path
try:
data = json.loads(Path("manifest/users.json").read_text())
except Exception:
print("invalid")
else:
print("present" if data.get("users") else "empty")
PY
)"
case "$user_state" in
present)
echo
echo "Existing users found in /opt/bl-proxy/manifest/users.json."
echo "Keeping them preserves existing JWT consumers and role assignments."
read -rp "Keep existing users? [Y/n]: " KEEP_USERS
KEEP_USERS="${KEEP_USERS:-Y}"
case "$KEEP_USERS" in
n|N|no|NO) reset_users=1 ;;
*) reset_users=0 ;;
esac
;;
invalid)
echo "manifest/users.json is not valid JSON. Fix it before re-running activation." >&2
exit 1
;;
esac
fi
if [ "$reset_users" = "1" ]; then
echo '{"users": {}}' > manifest/users.json
fi
echo
echo "Create initial users. You can add more later with:"
echo " sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>"
echo
created_any=0
if [ "$reset_users" = "1" ]; then
while true; do
read -rp "Create a user now? [Y/n]: " CREATE_USER
CREATE_USER="${CREATE_USER:-Y}"
case "$CREATE_USER" in
n|N|no|NO) break ;;
esac
read -rp "Username / JWT sub claim: " USER_KEY
echo "Available roles: readonly, support, senior-support, billing, admin"
read -rp "Role for $USER_KEY [readonly]: " ROLE
ROLE="${ROLE:-readonly}"
./scripts/add-user.sh "$USER_KEY" "$ROLE"
created_any=1
echo
done
if [ "$created_any" = "0" ]; then
echo "No users created. Creating a default readonly user named alice."
./scripts/add-user.sh alice readonly
fi
else
echo "Keeping existing users. Use add-user.sh to add users or rotate a user's secret."
fi
./scripts/deploy-scoped.sh
cat > DEPLOYMENT-NOTES.txt <<EOF
BinaryLane scoped API proxy deployment notes
Created: $(date -Is)
Proxy URL:
$PROXY_URL
Access mode:
$PUBLIC_MODE
User secrets:
Stored in /opt/bl-proxy/secrets/<username>.txt
Add another user:
sudo /opt/bl-proxy/scripts/add-user.sh dave readonly
sudo /opt/bl-proxy/scripts/add-user.sh erin support
Generate a JWT:
sudo /opt/bl-proxy/scripts/make-jwt.sh <username> '<user-secret>'
Use the proxy:
TOKEN=\$(sudo /opt/bl-proxy/scripts/make-jwt.sh <username> '<user-secret>')
curl -H "Authorization: Bearer \$TOKEN" $PROXY_URL/v2/servers
Change roles:
Edit /opt/bl-proxy/manifest/users.json, then run:
sudo /opt/bl-proxy/scripts/deploy-scoped.sh
Rotate a user secret:
sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>
This updates the APISIX consumer and invalidates JWTs signed with the old secret.
Change access mode:
Re-run sudo /opt/bl-proxy/scripts/activate.sh
Keep existing users when prompted unless you intentionally want to reset them.
Check status:
sudo /opt/bl-proxy/scripts/status.sh
Security notes:
- Do not expose APISIX admin port or etcd.
- Use HTTPS mode for public internet access.
- Use private HTTP mode only behind a VPC, VPN, bastion, SSH tunnel, or trusted private path.
- Rotate user secrets when access is no longer needed.
- Send JWTs only in the Authorization header, not in query strings.
EOF
chmod 600 DEPLOYMENT-NOTES.txt
echo
echo "Activation complete."
echo "Read /opt/bl-proxy/DEPLOYMENT-NOTES.txt for user management and authentication examples."
./scripts/status.sh || true
- path: /opt/bl-proxy/scripts/add-user.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
# Add or update a scoped API user.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
if [ "${PUBLIC_MODE:-}" = "private-http" ]; then
PROXY_URL="${PROXY_URL:-http://<vps-private-or-public-ip>}"
elif [ -n "${DOMAIN:-}" ]; then
PROXY_URL="${PROXY_URL:-https://$DOMAIN}"
else
PROXY_URL="${PROXY_URL:-https://<proxy-hostname>}"
fi
USER_KEY="${1:-}"
ROLE="${2:-}"
USER_SECRET="${3:-}"
if [ -z "$USER_KEY" ]; then
read -rp "Username / JWT sub claim: " USER_KEY
fi
if [ -z "$ROLE" ]; then
echo "Available roles: readonly, support, senior-support, billing, admin"
read -rp "Role for $USER_KEY: " ROLE
fi
case "$ROLE" in
readonly|support|senior-support|billing|admin) ;;
*)
echo "Unsupported role: $ROLE" >&2
exit 1
;;
esac
if [ -z "$USER_SECRET" ]; then
USER_SECRET="$(openssl rand -hex 32)"
fi
existing_user=0
if [ -f manifest/users.json ] && python3 - "$USER_KEY" <<'PY'
import json
import sys
from pathlib import Path
try:
data = json.loads(Path("manifest/users.json").read_text())
except Exception:
raise SystemExit(1)
raise SystemExit(0 if sys.argv[1] in data.get("users", {}) else 1)
PY
then
existing_user=1
fi
export USER_KEY USER_SECRET
./scripts/apisix-setup.sh >/tmp/bl-proxy-add-user.out
python3 - "$USER_KEY" "$ROLE" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path("manifest/users.json")
data = json.loads(path.read_text()) if path.exists() else {"users": {}}
data.setdefault("users", {})[sys.argv[1]] = [sys.argv[2]]
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
PY
./scripts/deploy-scoped.sh >/tmp/bl-proxy-deploy-scoped.out
mkdir -p secrets
chmod 700 secrets
{
echo "User: $USER_KEY"
echo "Role: $ROLE"
echo "Secret: $USER_SECRET"
echo "Created: $(date -Is)"
echo
echo "Generate a JWT:"
echo " /opt/bl-proxy/scripts/make-jwt.sh '$USER_KEY' '$USER_SECRET'"
echo
echo "Authenticate to the proxy:"
echo " TOKEN=\$(/opt/bl-proxy/scripts/make-jwt.sh '$USER_KEY' '$USER_SECRET')"
echo " curl -H \"Authorization: Bearer \$TOKEN\" $PROXY_URL/v2/servers"
} > "secrets/${USER_KEY}.txt"
chmod 600 "secrets/${USER_KEY}.txt"
if [ "$existing_user" = "1" ]; then
echo "User '$USER_KEY' updated with role '$ROLE'."
echo "The user's JWT secret has been rotated; old JWTs for this user are now invalid."
else
echo "User '$USER_KEY' registered with role '$ROLE'."
fi
echo "Secret saved to /opt/bl-proxy/secrets/${USER_KEY}.txt"
echo "Give the user their key, secret, and the JWT generation example from that file."
echo
echo "Add another user later with:"
echo " sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>"
echo
echo "Available roles: readonly, support, senior-support, billing, admin"
- path: /opt/bl-proxy/scripts/apisix-setup.sh
owner: root:root
permissions: '0755'
content: |
#!/bin/bash
# APISIX 3.17 setup for BinaryLane API proxy
# Run on the host after `docker compose up -d`
# Calls the APISIX admin API via the container's Docker network IP
#
# Required env vars:
# BL_API_TOKEN - Your BinaryLane master API token, or set in .env
# ADMIN_KEY - APISIX admin key; defaults to apisix/config.yaml
# USER_KEY - Consumer identity (e.g. "alice")
# USER_SECRET - Secret the user signs JWTs with (generate with `openssl rand -hex 32`)
#
# Usage:
# export BL_API_TOKEN="$(grep '^BL_API_TOKEN=' .env | cut -d= -f2-)"
# export USER_KEY="alice"
# export USER_SECRET="$(openssl rand -hex 32)"
# ./scripts/apisix-setup.sh
set -euo pipefail
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
BL_API_TOKEN="${BL_API_TOKEN:?Environment variable BL_API_TOKEN is required}"
ADMIN_KEY="${ADMIN_KEY:-$(python3 - <<'PY'
import re
s = open("apisix/config.yaml").read()
match = re.search(r"key: '([^']+)'", s)
if not match:
raise SystemExit("Could not read APISIX admin key from apisix/config.yaml")
print(match.group(1))
PY
)}"
USER_KEY="${USER_KEY:?Environment variable USER_KEY is required}"
USER_SECRET="${USER_SECRET:?Environment variable USER_SECRET is required}"
API_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' apisix)
ADMIN_URL="http://$API_IP:9180/apisix/admin"
echo "=== APISIX admin at $ADMIN_URL ==="
# Create upstream
echo "-- Creating upstream 'bl-api'"
curl -sf -X PUT \
-H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "roundrobin",
"scheme": "https",
"nodes": {"api.binarylane.com.au:443": 1}
}' "$ADMIN_URL/upstreams/bl-api" | jq .
# Create route (with JWT expiry enforcement)
echo "-- Creating route 'bl-api-proxy'"
curl -sf -X PUT \
-H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg token "$BL_API_TOKEN" \
'{
"uri": "/v2/*",
"methods": ["GET","POST","PUT","PATCH","DELETE"],
"upstream_id": "bl-api",
"plugins": {
"jwt-auth": {
"key_claim_name": "sub",
"claims_to_verify": ["exp"]
},
"proxy-rewrite": {
"headers": {
"Authorization": ("Bearer " + $token)
}
}
}
}'
)" "$ADMIN_URL/routes/bl-api-proxy" | jq .
# Create consumer
echo "-- Creating consumer '$USER_KEY'"
curl -sf -X PUT \
-H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg key "$USER_KEY" \
--arg secret "$USER_SECRET" \
'{
"username": $key,
"plugins": {
"jwt-auth": {
"key": $key,
"secret": $secret,
"algorithm": "HS256"
}
}
}'
)" "$ADMIN_URL/consumers/$USER_KEY" | jq .
echo ""
echo "=== Consumer '$USER_KEY' created ==="
echo ""
echo "Give the user these credentials:"
echo " Key (sub claim) : $USER_KEY"
echo " Secret : $USER_SECRET"
echo ""
echo "IMPORTANT:"
echo " - Tokens must include an 'exp' claim (APISIX will reject tokens without it)."
echo " - Generate short-lived tokens (e.g. 1 hour) and rotate regularly."
echo " - Do not store the secret in client-side code accessible to end users."
echo ""
# Generate a test JWT (1 hour expiry)
echo "--- Test JWT (expires in 1 hour) ---"
JWT="$(./scripts/make-jwt.sh "$USER_KEY" "$USER_SECRET")"
echo "$JWT"
echo ""
echo "--- Proxy test ---"
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:9080/v2/servers \
-H "Authorization: Bearer $JWT" | head -5
echo ""
echo "Done."
- path: /opt/bl-proxy/scripts/deploy-scoped.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
# Scoped access deployment - generates Lua policy from roles.json
# and deploys via serverless-pre-function inline approach.
# Run from /opt/bl-proxy after the Docker stack is active.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
BL_API_TOKEN="${BL_API_TOKEN:?Set BL_API_TOKEN in the environment or .env}"
API_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' apisix)
ADMIN_URL="http://$API_IP:9180/apisix/admin"
ADMIN_KEY=$(python3 -c "
import re
s = open('apisix/config.yaml').read()
print(re.search(r\"key: '([^']+)'\", s).group(1))
")
# Step 1: Generate Lua policy source from roles.json
python3 scripts/generate-policy-lua.py > /tmp/scoped-policy.lua
echo "[+] Generated /tmp/scoped-policy.lua ($(wc -l < /tmp/scoped-policy.lua) lines)"
# Step 2: Build route JSON with inline Lua + user-role config
python3 - "$BL_API_TOKEN" <<'PY' > /tmp/route-scoped.json
import json, sys
token = sys.argv[1]
lua = open("/tmp/scoped-policy.lua").read()
try:
with open("manifest/users.json") as f:
users = json.load(f)["users"]
except FileNotFoundError:
raise SystemExit("manifest/users.json is missing. Create it before deploying scoped mode.")
payload = {
"uri": "/v2/*",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"upstream_id": "bl-api",
"plugins": {
"jwt-auth": {
"key_claim_name": "sub",
"claims_to_verify": ["exp"],
},
"serverless-pre-function": {
"phase": "access",
"functions": [lua],
"policy": {}, # reserved for future use
"users": users,
},
"proxy-rewrite": {
"headers": {"Authorization": "Bearer " + token},
},
},
}
print(json.dumps(payload))
PY
# Step 3: Deploy route
curl -sf -X PUT \
-H "X-API-KEY: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d @/tmp/route-scoped.json \
"$ADMIN_URL/routes/bl-api-proxy" >/dev/null
echo "[+] Route bl-api-proxy updated"
echo ""
echo "=== Scoped access deployment complete ==="
echo "Users loaded from manifest/users.json"
echo "Run scripts/test-scoped-policy.sh to verify"
- path: /opt/bl-proxy/scripts/generate-policy-lua.py
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env python3
"""Generate a self-contained Lua policy module from manifest/roles.json."""
import json
roles = json.load(open("manifest/roles.json"))["roles"]
ACTION_TYPE_MAP = {
"reboot": "servers:reboot", "power_cycle": "servers:powercycle",
"power_on": "servers:poweron", "power_off": "servers:poweroff",
"shutdown": "servers:shutdown", "password_reset": "servers:passwordreset",
"rebuild": "servers:rebuild", "resize": "servers:resize",
"rename": "servers:rename", "enable_ipv6": "servers:enableipv6",
"change_ipv6": "servers:changeipv6", "change_kernel": "servers:changekernel",
"disable_selinux": "servers:disableselinux",
"change_port_blocking": "servers:changeportblocking",
"change_region": "servers:changeregion", "change_network": "servers:changenetwork",
"change_reverse_name": "servers:changereversename",
"change_advanced_features": "servers:changeadvancedfeatures",
"change_advanced_firewall_rules": "servers:changeadvancedfirewallrules",
"change_threshold_alerts": "servers:changethresholdalerts",
"enable_backups": "servers:enablebackups", "disable_backups": "servers:disablebackups",
"take_backup": "servers:takebackup", "restore": "servers:restore",
"change_backup_schedule": "servers:changebackupschedule",
"change_offsite_backup_location": "servers:changeoffsitebackuplocation",
"change_manage_offsite_backup_copies": "servers:changemanageoffsitebackupcopies",
"resize_disk": "servers:resizedisk", "add_disk": "servers:adddisk",
"delete_disk": "servers:deletedisk",
"clone_using_backup": "servers:cloneusingbackup",
"attach_backup": "servers:attachbackup", "detach_backup": "servers:detachbackup",
"change_vpc_ipv4": "servers:changevpcipv4",
"change_separate_private_network_interface": "servers:changeseparateprivatenetworkinterface",
"change_source_and_destination_check": "servers:changesourceanddestinationcheck",
"change_partner": "servers:changepartner", "uncancel": "servers:uncancel",
"change_ipv6_reverse_nameservers": "servers:changeipv6reversenameservers",
"is_running": "servers:isrunning", "ping": "servers:ping", "uptime": "servers:uptime",
}
def lua_str(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def lua_table(items):
return "{\n" + "\n".join(f" {lua_str(a)}," for a in sorted(items)) + "\n }"
# =====================================================================
# 1. Role data tables
# =====================================================================
print("-- Auto-generated from manifest/roles.json")
print("-- Role → action mappings")
print("local _ROLES = {")
for role_name, role_data in sorted(roles.items()):
print(f" [{lua_str(role_name)}] = {lua_table(set(role_data['actions']))},")
print("}")
# =====================================================================
# 2. Action type map
# =====================================================================
print()
print("-- Server action type → action name")
print("local _ACTION_TYPES = {")
for k, v in sorted(ACTION_TYPE_MAP.items()):
print(f" [{lua_str(k)}] = {lua_str(v)},")
print("}")
# =====================================================================
# 3. Path resolver
# =====================================================================
print()
print("local function _resolve(method, path)")
# GET collections
print(" local _get_coll = {")
for pat, a in sorted([
("^/v2/account$", "accounts:list"),
("^/v2/actions$", "actions:list"),
("^/v2/customers/my/invoices$", "customers:list"),
("^/v2/customers/my/unpaid%-payment%-failed%-invoices$", "customers:list"),
("^/v2/customers/my/balance$", "customers:list"),
("^/v2/data_usages/current$", "datausages:list"),
("^/v2/domains$", "domains:list"),
("^/v2/domains/nameservers$", "domains:list"),
("^/v2/images$", "images:list"),
("^/v2/load_balancers$", "loadbalancers:list"),
("^/v2/load_balancers/availability$", "loadbalancers:list"),
("^/v2/account/keys$", "keys:list"),
("^/v2/regions$", "regions:list"),
("^/v2/reverse_names/ipv6$", "reversenames:list"),
("^/v2/servers$", "servers:list"),
("^/v2/servers/threshold_alerts$", "servers:list"),
("^/v2/sizes$", "sizes:list"),
("^/v2/software$", "software:list"),
("^/v2/vpcs$", "vpcs:list"),
]):
print(f" [{lua_str(pat)}] = {lua_str(a)},")
print(" }")
print(" if method == 'GET' then")
print(" for p, a in pairs(_get_coll) do")
print(" if ngx.re.find(path, p, 'jo') then return a end")
print(" end")
print(" end")
# POST creates
print()
print(" local _post_create = {")
for pat, a in sorted([
("^/v2/domains$", "domains:create"),
("^/v2/load_balancers$", "loadbalancers:create"),
("^/v2/account/keys$", "keys:create"),
("^/v2/reverse_names/ipv6$", "reversenames:create"),
("^/v2/servers$", "servers:create"),
("^/v2/vpcs$", "vpcs:create"),
("^/v2/domains/refresh_nameserver_cache$", "domains:refresh_nameservers"),
("^/v2/domains/[^/]+/records$", "domains:create"),
]):
print(f" [{lua_str(pat)}] = {lua_str(a)},")
print(" }")
print(" if method == 'POST' then")
print(" for p, a in pairs(_post_create) do")
print(" if ngx.re.find(path, p, 'jo') then return a end")
print(" end")
print(" end")
# ID patterns
print()
print(" local _id_pats = {")
for pat, a in sorted([
("^/v2/actions/[^/]+$", "actions:read"),
("^/v2/customers/my/invoices/[^/]+$", "customers:read"),
("^/v2/data_usages/[^/]+/current$", "datausages:read"),
("^/v2/domains/[^/]+$", "domains:read"),
("^/v2/domains/[^/]+/records$", "domains:read"),
("^/v2/domains/[^/]+/records/[^/]+$", "domains:read"),
("^/v2/images/[^/]+$", "images:read"),
("^/v2/load_balancers/[^/]+$", "loadbalancers:read"),
("^/v2/account/keys/[^/]+$", "keys:read"),
("^/v2/samplesets/[^/]+$", "samplesets:read"),
("^/v2/samplesets/[^/]+/latest$", "samplesets:read"),
("^/v2/servers/[^/]+$", "servers:read"),
("^/v2/servers/[^/]+/actions$", "serveractions:read"),
("^/v2/servers/[^/]+/actions/[^/]+$", "servers:read"),
("^/v2/servers/[^/]+/advanced_firewall_rules$", "servers:read"),
("^/v2/servers/[^/]+/available_advanced_features$", "servers:read"),
("^/v2/servers/[^/]+/backups$", "servers:read"),
("^/v2/servers/[^/]+/console$", "servers:read"),
("^/v2/servers/[^/]+/kernels$", "servers:read"),
("^/v2/servers/[^/]+/snapshots$", "servers:read"),
("^/v2/servers/[^/]+/software$", "servers:read"),
("^/v2/servers/[^/]+/threshold_alerts$", "servers:read"),
("^/v2/servers/[^/]+/user_data$", "servers:read"),
("^/v2/software/[^/]+$", "software:read"),
("^/v2/software/operating_system/[^/]+$", "software:read"),
("^/v2/vpcs/[^/]+$", "vpcs:read"),
("^/v2/vpcs/[^/]+/members$", "vpcs:read"),
]):
print(f" [{lua_str(pat)}] = {lua_str(a)},")
print(" }")
print(" for p, a in pairs(_id_pats) do")
print(" if ngx.re.find(path, p, 'jo') then")
print(" if method == 'GET' then return a")
print(" elseif method == 'PUT' or method == 'PATCH' then")
print(" return a:gsub(':read$', ':update')")
print(" elseif method == 'DELETE' then")
print(" return a:gsub(':read$', ':delete')")
print(" end")
print(" end")
print(" end")
# LB sub-resources
print()
print(" if ngx.re.find(path, [[^/v2/load_balancers/[^/]+/forwarding_rules$]], 'jo') then")
print(" if method == 'POST' then return 'loadbalancers:create'")
print(" elseif method == 'DELETE' then return 'loadbalancers:delete'")
print(" end")
print(" end")
print(" if ngx.re.find(path, [[^/v2/load_balancers/[^/]+/servers$]], 'jo') then")
print(" if method == 'POST' then return 'loadbalancers:create'")
print(" elseif method == 'DELETE' then return 'loadbalancers:delete'")
print(" end")
print(" end")
print(" if method == 'POST' and ngx.re.find(path, [[^/v2/actions/[^/]+/proceed$]], 'jo') then")
print(" return 'actions:action'")
print(" end")
print(" if method == 'GET' and ngx.re.find(path, [[^/v2/images/[^/]+/download$]], 'jo') then")
print(" return 'images:read'")
print(" end")
print(" if method == 'POST' and ngx.re.find(path, [[^/v2/servers/[^/]+/backups$]], 'jo') then")
print(" return 'servers:create'")
print(" end")
print()
print(" return nil")
print("end")
# =====================================================================
# 4. Main policy function
# =====================================================================
print()
print("local cjson = require 'cjson'")
print("return function(conf, ctx)")
print(" local username = (ctx.consumer or {}).username")
print(" if not username then")
print(" return 403, {error='missing_user', message='Authenticated consumer not available'}")
print(" end")
print()
print(" -- Resolve roles from conf.users[username]")
print(" local user_roles = {}")
print(" if conf.users and conf.users[username] then")
print(" for _, r in ipairs(conf.users[username]) do")
print(" user_roles[r] = true")
print(" end")
print(" end")
print(" if next(user_roles) == nil then")
print(" return 403, {error='forbidden', user=username, message='no roles assigned'}")
print(" end")
print()
print(" -- Collect allowed actions across all roles")
print(" local allowed = {}")
print(" for role, _ in pairs(user_roles) do")
print(" local actions = _ROLES[role]")
print(" if actions then")
print(" for _, a in ipairs(actions) do")
print(" allowed[a] = true")
print(" end")
print(" end")
print(" end")
print()
print(" -- Determine requested action")
print(" local method = ngx.req.get_method()")
print(" local path = ngx.var.uri")
print(" local action = nil")
print()
print(" -- Special: POST /v2/servers/{id}/actions → parse JSON body for 'type'")
print(" if method == 'POST' and ngx.re.find(path, [[^/v2/servers/[^/]+/actions$]], 'jo') then")
print(" ngx.req.read_body()")
print(" local body = ngx.req.get_body_data()")
print(" if body then")
print(" local ok, tbl = pcall(cjson.decode, body)")
print(" if ok and tbl.type then")
print(" action = _ACTION_TYPES[tbl.type]")
print(" end")
print(" end")
print(" else")
print(" action = _resolve(method, path)")
print(" end")
print()
print(" -- Unknown or unmapped /v2 path: fail closed so new API endpoints do not bypass role checks.")
print(" if not action then")
print(" return 403, {")
print(" error = 'unknown_endpoint',")
print(" user = username,")
print(" method = method,")
print(" path = path,")
print(" message = 'no permission rule matches this request',")
print(" }")
print(" end")
print()
print(" -- Enforce")
print(" if not allowed[action] then")
print(" return 403, {")
print(" error = 'forbidden',")
print(" user = username,")
print(" method = method,")
print(" path = path,")
print(" action = action,")
print(" message = 'role does not permit ' .. action,")
print(" }")
print(" end")
print("end")
- path: /opt/bl-proxy/scripts/make-jwt.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
# Generate a short-lived JWT for an APISIX consumer.
set -euo pipefail
USER_KEY="${1:-${USER_KEY:-}}"
USER_SECRET="${2:-${USER_SECRET:-}}"
EXP_SECONDS="${3:-3600}"
if [ -z "$USER_KEY" ] || [ -z "$USER_SECRET" ]; then
echo "Usage: $0 <user-key> <user-secret> [expiry-seconds]" >&2
exit 1
fi
python3 - "$USER_KEY" "$USER_SECRET" "$EXP_SECONDS" <<'PY'
import base64
import hashlib
import hmac
import json
import sys
import time
user, secret, exp_seconds = sys.argv[1], sys.argv[2], int(sys.argv[3])
def b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64(json.dumps({"sub": user, "exp": int(time.time()) + exp_seconds}, separators=(",", ":")).encode())
sig = b64(hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
- path: /opt/bl-proxy/scripts/status.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
# Show scoped proxy status without printing secrets.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== Containers =="
docker compose ps
echo
echo "== Environment =="
if [ -f .env ]; then
grep -E '^(DOMAIN|PUBLIC_MODE)=' .env || true
else
echo ".env not created yet. Run /opt/bl-proxy/scripts/activate.sh"
fi
echo
echo "== Scoped users =="
if [ -f manifest/users.json ]; then
python3 - <<'PY'
import json
data = json.load(open("manifest/users.json"))
for user, roles in sorted(data.get("users", {}).items()):
print(f"{user}: {', '.join(roles)}")
PY
else
echo "manifest/users.json not found"
fi
echo
echo "== Local proxy checks =="
curl -s -o /dev/null -w 'No token /v2/servers: HTTP %{http_code}\n' http://127.0.0.1:9080/v2/servers || true
- path: /opt/bl-proxy/scripts/test-scoped-policy.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
# Behaviour tests for the scoped-access policy.
#
# Safe by default: this does not send allowed mutating requests upstream. To
# include the live support reboot proof, set RUN_LIVE_ACTION_TESTS=1 and
# TEST_SERVER_ID=<real test server id>.
set -euo pipefail
cd "$(dirname "$0")/.."
BASE_URL="${BASE_URL:-http://127.0.0.1:9080}"
TEST_SERVER_ID="${TEST_SERVER_ID:-0}"
RUN_LIVE_ACTION_TESTS="${RUN_LIVE_ACTION_TESTS:-0}"
if [ "$TEST_SERVER_ID" = "0" ]; then
echo "Using TEST_SERVER_ID=0. This is a safe placeholder; upstream read checks may return 404."
echo "Set TEST_SERVER_ID to one of your own test server IDs for live action checks."
echo
fi
secret_from_file() {
local user=$1
local var_name=$2
local value="${!var_name:-}"
local secret_file="secrets/${user}.txt"
if [ -n "$value" ]; then
printf '%s' "$value"
return
fi
if [ -f "$secret_file" ]; then
awk -F': ' '/^Secret: / {print $2; exit}' "$secret_file"
return
fi
echo "Missing secret for $user." >&2
echo "Set $var_name, or create $secret_file with scripts/add-user.sh." >&2
exit 1
}
make_jwt() {
local user=$1 secret=$2 exp_delta=${3:-3600}
python3 - "$user" "$secret" "$exp_delta" <<'PY'
import base64
import hashlib
import hmac
import json
import sys
import time
user, secret, exp_delta = sys.argv[1], sys.argv[2], int(sys.argv[3])
def b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64(json.dumps({"sub": user, "exp": int(time.time()) + exp_delta}, separators=(",", ":")).encode())
sig = b64(hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
}
ALICE_SECRET="$(secret_from_file alice ALICE_SECRET)"
BOB_SECRET="$(secret_from_file bob BOB_SECRET)"
CHARLIE_SECRET="$(secret_from_file charlie CHARLIE_SECRET)"
ALICE=$(make_jwt alice "$ALICE_SECRET")
BOB=$(make_jwt bob "$BOB_SECRET")
CHARLIE=$(make_jwt charlie "$CHARLIE_SECRET")
EXPIRED=$(make_jwt alice "$ALICE_SECRET" -60)
failures=0
run() {
local label=$1 expected=$2
shift 2
local code
code=$(curl -s -o /tmp/test-scoped-policy-body.out -w '%{http_code}' "$@")
printf '%-65s expected=%s got=%s ' "$label" "$expected" "$code"
if [ "$code" = "$expected" ]; then
echo OK
else
echo FAIL
cat /tmp/test-scoped-policy-body.out
echo
failures=$((failures + 1))
fi
}
run_any() {
local label=$1 expected_list=$2
shift 2
local code
code=$(curl -s -o /tmp/test-scoped-policy-body.out -w '%{http_code}' "$@")
printf '%-65s expected=%s got=%s ' "$label" "$expected_list" "$code"
case ",$expected_list," in
*",$code,"*) echo OK ;;
*)
echo FAIL
cat /tmp/test-scoped-policy-body.out
echo
failures=$((failures + 1))
;;
esac
}
run 'no token GET /v2/servers' 401 "$BASE_URL/v2/servers"
run 'expired token GET /v2/servers' 401 -H "Authorization: Bearer $EXPIRED" "$BASE_URL/v2/servers"
run 'alice readonly GET /v2/servers' 200 -H "Authorization: Bearer $ALICE" "$BASE_URL/v2/servers"
run_any 'alice readonly GET /v2/servers/{id} reaches upstream' 200,404 -H "Authorization: Bearer $ALICE" "$BASE_URL/v2/servers/$TEST_SERVER_ID"
run 'alice readonly POST /v2/servers denied before upstream' 403 -X POST -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{}' "$BASE_URL/v2/servers"
run 'alice readonly POST reboot denied before upstream' 403 -X POST -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"type":"reboot"}' "$BASE_URL/v2/servers/$TEST_SERVER_ID/actions"
run 'bob support GET /v2/servers' 200 -H "Authorization: Bearer $BOB" "$BASE_URL/v2/servers"
run 'bob support POST rebuild denied before upstream' 403 -X POST -H "Authorization: Bearer $BOB" -H 'Content-Type: application/json' -d '{"type":"rebuild"}' "$BASE_URL/v2/servers/$TEST_SERVER_ID/actions"
run 'bob support unknown action type fails closed' 403 -X POST -H "Authorization: Bearer $BOB" -H 'Content-Type: application/json' -d '{"type":"future_unknown_action"}' "$BASE_URL/v2/servers/$TEST_SERVER_ID/actions"
run 'valid JWT unknown endpoint fails closed' 403 -H "Authorization: Bearer $ALICE" "$BASE_URL/v2/unknown-policy-check"
run 'charlie admin GET /v2/servers' 200 -H "Authorization: Bearer $CHARLIE" "$BASE_URL/v2/servers"
if [ "$RUN_LIVE_ACTION_TESTS" = "1" ]; then
run 'bob support POST reboot allowed live action' 200 -X POST -H "Authorization: Bearer $BOB" -H 'Content-Type: application/json' -d '{"type":"reboot"}' "$BASE_URL/v2/servers/$TEST_SERVER_ID/actions"
else
echo "Skipping allowed live mutating action test. Set RUN_LIVE_ACTION_TESTS=1 to include it."
fi
if [ "$failures" -gt 0 ]; then
echo "$failures test(s) failed"
exit 1
fi
echo "All scoped policy tests passed."
- path: /opt/bl-proxy/manifest/roles.json
owner: root:root
permissions: '0644'
content: |
{
"description": "Role definitions for the BL scoped gateway. Each role lists actions generated into the APISIX Lua policy.",
"roles": {
"readonly": {
"description": "View-only access. Can read servers, domains, keys, invoices, and metadata. Cannot modify anything.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"images:list",
"images:read",
"keys:list",
"keys:read",
"loadbalancers:list",
"loadbalancers:read",
"regions:list",
"reversenames:list",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read"
]
},
"support": {
"description": "Junior support - readonly plus server power/reboot, password resets, and backup operations. No destructive actions.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"actions:action",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"domains:create",
"domains:update",
"domains:refresh_nameservers",
"images:list",
"images:read",
"keys:list",
"keys:read",
"loadbalancers:list",
"loadbalancers:read",
"regions:list",
"reversenames:list",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"servers:poweron",
"servers:poweroff",
"servers:powercycle",
"servers:reboot",
"servers:shutdown",
"servers:passwordreset",
"servers:enablebackups",
"servers:disablebackups",
"servers:takebackup",
"servers:restore",
"servers:changethresholdalerts",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read"
]
},
"senior-support": {
"description": "Senior support - all support actions plus resize, rebuild, network changes, kernel changes, and server cancellation.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"actions:action",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"domains:create",
"domains:update",
"domains:delete",
"domains:refresh_nameservers",
"images:list",
"images:read",
"images:update",
"keys:list",
"keys:read",
"keys:create",
"keys:update",
"keys:delete",
"loadbalancers:list",
"loadbalancers:read",
"loadbalancers:create",
"loadbalancers:update",
"loadbalancers:delete",
"regions:list",
"reversenames:list",
"reversenames:create",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"servers:create",
"servers:delete",
"servers:poweron",
"servers:poweroff",
"servers:powercycle",
"servers:reboot",
"servers:shutdown",
"servers:passwordreset",
"servers:enablebackups",
"servers:disablebackups",
"servers:takebackup",
"servers:restore",
"servers:rebuild",
"servers:resize",
"servers:rename",
"servers:resizedisk",
"servers:adddisk",
"servers:deletedisk",
"servers:changebackupschedule",
"servers:changeipv6",
"servers:enableipv6",
"servers:changeoffsitebackuplocation",
"servers:changemanageoffsitebackupcopies",
"servers:changekernel",
"servers:disableselinux",
"servers:changeportblocking",
"servers:changeregion",
"servers:changenetwork",
"servers:changereversename",
"servers:changeipv6reversenameservers",
"servers:changeadvancedfeatures",
"servers:changeadvancedfirewallrules",
"servers:changethresholdalerts",
"servers:cloneusingbackup",
"servers:attachbackup",
"servers:detachbackup",
"servers:changevpcipv4",
"servers:changeseparateprivatenetworkinterface",
"servers:changesourceanddestinationcheck",
"servers:changepartner",
"servers:uncancel",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read",
"vpcs:create",
"vpcs:update",
"vpcs:delete"
]
},
"billing": {
"description": "Billing-only access. Can read account, invoices, balance, and usage data. No server operations.",
"actions": [
"accounts:list",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"servers:list",
"servers:read",
"sizes:list"
]
},
"admin": {
"description": "Full access to all explicitly mapped gateway actions. Unknown or newly added API endpoints still fail closed until the policy is updated.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"actions:action",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"domains:create",
"domains:update",
"domains:delete",
"domains:refresh_nameservers",
"images:list",
"images:read",
"images:update",
"keys:list",
"keys:read",
"keys:create",
"keys:update",
"keys:delete",
"loadbalancers:list",
"loadbalancers:read",
"loadbalancers:create",
"loadbalancers:update",
"loadbalancers:delete",
"regions:list",
"reversenames:list",
"reversenames:create",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:create",
"servers:delete",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"servers:poweron",
"servers:poweroff",
"servers:powercycle",
"servers:reboot",
"servers:shutdown",
"servers:passwordreset",
"servers:enablebackups",
"servers:disablebackups",
"servers:takebackup",
"servers:restore",
"servers:rebuild",
"servers:resize",
"servers:rename",
"servers:resizedisk",
"servers:adddisk",
"servers:deletedisk",
"servers:changebackupschedule",
"servers:changeipv6",
"servers:enableipv6",
"servers:changeoffsitebackuplocation",
"servers:changemanageoffsitebackupcopies",
"servers:changekernel",
"servers:disableselinux",
"servers:changeportblocking",
"servers:changeregion",
"servers:changenetwork",
"servers:changereversename",
"servers:changeipv6reversenameservers",
"servers:changeadvancedfeatures",
"servers:changeadvancedfirewallrules",
"servers:changethresholdalerts",
"servers:cloneusingbackup",
"servers:attachbackup",
"servers:detachbackup",
"servers:changevpcipv4",
"servers:changeseparateprivatenetworkinterface",
"servers:changesourceanddestinationcheck",
"servers:changepartner",
"servers:uncancel",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read",
"vpcs:create",
"vpcs:update",
"vpcs:delete"
]
}
}
}
- path: /opt/bl-proxy/manifest/users.json
owner: root:root
permissions: '0644'
content: |
{
"users": {}
}
runcmd:
- mkdir -p /opt/bl-proxy/apisix/logs /opt/bl-proxy/caddy /opt/bl-proxy/scripts /opt/bl-proxy/manifest /opt/bl-proxy/secrets
- chown root:636 /opt/bl-proxy/apisix/config.yaml /opt/bl-proxy/apisix/logs
- chmod 640 /opt/bl-proxy/apisix/config.yaml
- chmod 770 /opt/bl-proxy/apisix/logs
- chmod 700 /opt/bl-proxy/secrets
- bash -lc 'if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh; fi'
- bash -lc 'ADMIN_KEY="$(openssl rand -hex 32)"; sed -i "s/__ADMIN_KEY__/$ADMIN_KEY/g" /opt/bl-proxy/apisix/config.yaml'
- bash -lc 'cd /opt/bl-proxy && docker compose pull'
- bash -lc 'cat /opt/bl-proxy/README-FIRST.txt'
final_message: "BinaryLane scoped API proxy bootstrap complete. SSH in and run: sudo /opt/bl-proxy/scripts/activate.sh"
1.2 Activate the Proxy
Run:
sudo /opt/bl-proxy/scripts/activate.sh
The activation script asks for:
- your BinaryLane API token
- access mode
- initial users and roles
The API token is written to /opt/bl-proxy/.env with root-only permissions. It is not stored in the cloud-init payload.
If you re-run the activation script later to change access mode, keep the existing users when prompted unless you intentionally want to reset user access. Use add-user.sh for normal user additions and secret rotation.
Show activation script
#!/usr/bin/env bash
# First-run activation for the BinaryLane scoped API proxy appliance.
set -euo pipefail
cd "$(dirname "$0")/.."
umask 077
echo "BinaryLane scoped API proxy activation"
echo
echo "This script stores your BinaryLane API token locally in /opt/bl-proxy/.env"
echo "with root-only permissions. It is not included in cloud-init user data."
echo
read -rsp "BinaryLane API token: " BL_API_TOKEN
echo
if [ -z "$BL_API_TOKEN" ]; then
echo "API token is required." >&2
exit 1
fi
echo
echo "Choose access mode:"
echo " 1) Public HTTPS with a DNS name (recommended for internet access)"
echo " 2) Private/IP-only HTTP for VPC, VPN, bastion, or testing"
read -rp "Access mode [1]: " ACCESS_MODE
ACCESS_MODE="${ACCESS_MODE:-1}"
PUBLIC_MODE="https-domain"
DOMAIN=""
if [ "$ACCESS_MODE" = "2" ]; then
PUBLIC_MODE="private-http"
else
read -rp "DNS name for this proxy, e.g. bl-proxy.example.com: " DOMAIN
if [ -z "$DOMAIN" ]; then
echo "A DNS name is required for public HTTPS mode." >&2
exit 1
fi
fi
cat > .env <<EOF
BL_API_TOKEN=$BL_API_TOKEN
DOMAIN=$DOMAIN
PUBLIC_MODE=$PUBLIC_MODE
EOF
chmod 600 .env
if [ "$PUBLIC_MODE" = "private-http" ]; then
cat > caddy/Caddyfile <<'EOF'
:80 {
reverse_proxy apisix:9080
encode gzip
log {
output file /data/access.log {
roll_size 50mb
roll_keep 5
}
}
}
EOF
PROXY_URL="http://<vps-private-or-public-ip>"
else
cat > caddy/Caddyfile <<'EOF'
{$DOMAIN} {
reverse_proxy apisix:9080
encode gzip
log {
output file /data/access.log {
roll_size 50mb
roll_keep 5
}
}
}
EOF
PROXY_URL="https://$DOMAIN"
fi
echo
echo "Starting Docker stack..."
docker compose up -d
docker compose restart caddy >/dev/null 2>&1 || true
echo "Waiting for APISIX admin API..."
for _ in $(seq 1 60); do
if docker inspect apisix >/dev/null 2>&1; then
API_IP="$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' apisix)"
ADMIN_KEY="$(python3 - <<'PY'
import re
s = open("apisix/config.yaml").read()
print(re.search(r"key: '([^']+)'", s).group(1))
PY
)"
if curl -fsS -H "X-API-KEY: $ADMIN_KEY" "http://$API_IP:9180/apisix/admin/routes" >/dev/null 2>&1; then
break
fi
fi
sleep 2
done
mkdir -p secrets
chmod 700 secrets
reset_users=1
if [ -s manifest/users.json ]; then
user_state="$(python3 - <<'PY'
import json
from pathlib import Path
try:
data = json.loads(Path("manifest/users.json").read_text())
except Exception:
print("invalid")
else:
print("present" if data.get("users") else "empty")
PY
)"
case "$user_state" in
present)
echo
echo "Existing users found in /opt/bl-proxy/manifest/users.json."
echo "Keeping them preserves existing JWT consumers and role assignments."
read -rp "Keep existing users? [Y/n]: " KEEP_USERS
KEEP_USERS="${KEEP_USERS:-Y}"
case "$KEEP_USERS" in
n|N|no|NO) reset_users=1 ;;
*) reset_users=0 ;;
esac
;;
invalid)
echo "manifest/users.json is not valid JSON. Fix it before re-running activation." >&2
exit 1
;;
esac
fi
if [ "$reset_users" = "1" ]; then
echo '{"users": {}}' > manifest/users.json
fi
echo
echo "Create initial users. You can add more later with:"
echo " sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>"
echo
created_any=0
if [ "$reset_users" = "1" ]; then
while true; do
read -rp "Create a user now? [Y/n]: " CREATE_USER
CREATE_USER="${CREATE_USER:-Y}"
case "$CREATE_USER" in
n|N|no|NO) break ;;
esac
read -rp "Username / JWT sub claim: " USER_KEY
echo "Available roles: readonly, support, senior-support, billing, admin"
read -rp "Role for $USER_KEY [readonly]: " ROLE
ROLE="${ROLE:-readonly}"
./scripts/add-user.sh "$USER_KEY" "$ROLE"
created_any=1
echo
done
if [ "$created_any" = "0" ]; then
echo "No users created. Creating a default readonly user named alice."
./scripts/add-user.sh alice readonly
fi
else
echo "Keeping existing users. Use add-user.sh to add users or rotate a user's secret."
fi
./scripts/deploy-scoped.sh
cat > DEPLOYMENT-NOTES.txt <<EOF
BinaryLane scoped API proxy deployment notes
Created: $(date -Is)
Proxy URL:
$PROXY_URL
Access mode:
$PUBLIC_MODE
User secrets:
Stored in /opt/bl-proxy/secrets/<username>.txt
Add another user:
sudo /opt/bl-proxy/scripts/add-user.sh dave readonly
sudo /opt/bl-proxy/scripts/add-user.sh erin support
Generate a JWT:
sudo /opt/bl-proxy/scripts/make-jwt.sh <username> '<user-secret>'
Use the proxy:
TOKEN=\$(sudo /opt/bl-proxy/scripts/make-jwt.sh <username> '<user-secret>')
curl -H "Authorization: Bearer \$TOKEN" $PROXY_URL/v2/servers
Change roles:
Edit /opt/bl-proxy/manifest/users.json, then run:
sudo /opt/bl-proxy/scripts/deploy-scoped.sh
Rotate a user secret:
sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>
This updates the APISIX consumer and invalidates JWTs signed with the old secret.
Change access mode:
Re-run sudo /opt/bl-proxy/scripts/activate.sh
Keep existing users when prompted unless you intentionally want to reset them.
Check status:
sudo /opt/bl-proxy/scripts/status.sh
Security notes:
- Do not expose APISIX admin port or etcd.
- Use HTTPS mode for public internet access.
- Use private HTTP mode only behind a VPC, VPN, bastion, SSH tunnel, or trusted private path.
- Rotate user secrets when access is no longer needed.
- Send JWTs only in the Authorization header, not in query strings.
EOF
chmod 600 DEPLOYMENT-NOTES.txt
echo
echo "Activation complete."
echo "Read /opt/bl-proxy/DEPLOYMENT-NOTES.txt for user management and authentication examples."
./scripts/status.sh || true1.3 Choose an Access Mode
The activation script supports two access modes.
Public HTTPS with a DNS name is recommended when the proxy will be reachable from the internet. Create an IPv4 A record pointing the DNS name at the VPS public IPv4 address first, then choose this mode. Caddy will request a Let's Encrypt certificate and users connect with https://your-proxy-name.example.com.
Private/IP-only HTTP is for VPC, VPN, bastion, SSH tunnel, or test deployments where the proxy is not exposed directly to the public internet. Do not use plain HTTP for public internet access. If you use this mode, protect connectivity with a private network path or a separate gateway/bastion design.
1.4 Create Initial Users
During activation, create one or more initial users. Each user needs a username and role.
Available roles:
These are template roles. Review manifest/roles.json before using them in production, especially for support, senior-support, and admin users.
readonly- read-only accesssupport- read-only plus selected support operationssenior-support- broader operational accessbilling- account, invoice, balance, and usage accessadmin- access to all explicitly mapped gateway actions. Unknown or newly added API endpoints still fail closed until the policy is updated.
The script generates a JWT signing secret for each user and stores it in:
/opt/bl-proxy/secrets/<username>.txt
Keep these files secure. Give each user only their own key and secret.
1.5 Read the Deployment Notes
After activation, read:
sudo cat /opt/bl-proxy/DEPLOYMENT-NOTES.txt
The notes show:
- proxy URL
- configured access mode
- where user secrets are stored
- how to add users
- how to generate JWTs
- how to call the proxy
- how to redeploy after changing roles
1.6 Add More Users Later
To add another user after activation:
sudo /opt/bl-proxy/scripts/add-user.sh dave readonly sudo /opt/bl-proxy/scripts/add-user.sh erin support
The script registers the APISIX JWT consumer, updates manifest/users.json, redeploys scoped mode, and writes the user's secret to /opt/bl-proxy/secrets/.
To rotate a user's JWT signing secret, run add-user.sh again for the same username and role. This updates the APISIX consumer, writes a new secret file, and invalidates JWTs signed with the previous secret.
Show add-user helper script
#!/usr/bin/env bash
# Add or update a scoped API user.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
if [ "${PUBLIC_MODE:-}" = "private-http" ]; then
PROXY_URL="${PROXY_URL:-http://<vps-private-or-public-ip>}"
elif [ -n "${DOMAIN:-}" ]; then
PROXY_URL="${PROXY_URL:-https://$DOMAIN}"
else
PROXY_URL="${PROXY_URL:-https://<proxy-hostname>}"
fi
USER_KEY="${1:-}"
ROLE="${2:-}"
USER_SECRET="${3:-}"
if [ -z "$USER_KEY" ]; then
read -rp "Username / JWT sub claim: " USER_KEY
fi
if [ -z "$ROLE" ]; then
echo "Available roles: readonly, support, senior-support, billing, admin"
read -rp "Role for $USER_KEY: " ROLE
fi
case "$ROLE" in
readonly|support|senior-support|billing|admin) ;;
*)
echo "Unsupported role: $ROLE" >&2
exit 1
;;
esac
if [ -z "$USER_SECRET" ]; then
USER_SECRET="$(openssl rand -hex 32)"
fi
existing_user=0
if [ -f manifest/users.json ] && python3 - "$USER_KEY" <<'PY'
import json
import sys
from pathlib import Path
try:
data = json.loads(Path("manifest/users.json").read_text())
except Exception:
raise SystemExit(1)
raise SystemExit(0 if sys.argv[1] in data.get("users", {}) else 1)
PY
then
existing_user=1
fi
export USER_KEY USER_SECRET
./scripts/apisix-setup.sh >/tmp/bl-proxy-add-user.out
python3 - "$USER_KEY" "$ROLE" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path("manifest/users.json")
data = json.loads(path.read_text()) if path.exists() else {"users": {}}
data.setdefault("users", {})[sys.argv[1]] = [sys.argv[2]]
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
PY
./scripts/deploy-scoped.sh >/tmp/bl-proxy-deploy-scoped.out
mkdir -p secrets
chmod 700 secrets
{
echo "User: $USER_KEY"
echo "Role: $ROLE"
echo "Secret: $USER_SECRET"
echo "Created: $(date -Is)"
echo
echo "Generate a JWT:"
echo " /opt/bl-proxy/scripts/make-jwt.sh '$USER_KEY' '$USER_SECRET'"
echo
echo "Authenticate to the proxy:"
echo " TOKEN=\$(/opt/bl-proxy/scripts/make-jwt.sh '$USER_KEY' '$USER_SECRET')"
echo " curl -H \"Authorization: Bearer \$TOKEN\" $PROXY_URL/v2/servers"
} > "secrets/${USER_KEY}.txt"
chmod 600 "secrets/${USER_KEY}.txt"
if [ "$existing_user" = "1" ]; then
echo "User '$USER_KEY' updated with role '$ROLE'."
echo "The user's JWT secret has been rotated; old JWTs for this user are now invalid."
else
echo "User '$USER_KEY' registered with role '$ROLE'."
fi
echo "Secret saved to /opt/bl-proxy/secrets/${USER_KEY}.txt"
echo "Give the user their key, secret, and the JWT generation example from that file."
echo
echo "Add another user later with:"
echo " sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>"
echo
echo "Available roles: readonly, support, senior-support, billing, admin"1.7 Connect and Authenticate
Generate a JWT for a user:
TOKEN=$(sudo /opt/bl-proxy/scripts/make-jwt.sh dave '<secret-from-/opt/bl-proxy/secrets/dave.txt>')
Call the proxy:
curl -H "Authorization: Bearer $TOKEN" https://your-proxy-name.example.com/v2/servers
For private/IP-only mode, use the private or tunnelled URL instead, for example:
curl -H "Authorization: Bearer $TOKEN" http://10.250.0.10/v2/servers
Real response, captured from a live Quickstart deployment (private/IP-only mode, called over its public IP): HTTP 200. The body lists your account's own servers - not shown here since real output contains your account's infrastructure details.
For more real request/response examples, everyday usage patterns, and things to keep in mind, see 3.5.8 Using the Scoped Proxy - it applies the same way regardless of whether you deployed via Quickstart or Manual Setup.
1.8 Check Status
Run:
sudo /opt/bl-proxy/scripts/status.sh
This shows container state, access mode, configured scoped users, and a local no-token API check. It does not print your BinaryLane API token.
Real output, captured from a live Quickstart deployment:
== Containers == NAME IMAGE COMMAND SERVICE STATUS PORTS apisix apache/apisix:3.17.0-debian "/docker-entrypoint...." apisix Up 26 seconds 127.0.0.1:9080->9080/tcp, 9443/tcp caddy caddy:2-alpine "caddy run --config..." caddy Up 26 seconds 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 443/udp, 2019/tcp etcd quay.io/coreos/etcd:v3.5.9 "etcd --listen-clie..." etcd Up 26 seconds 2379-2380/tcp == Environment == DOMAIN= PUBLIC_MODE=private-http == Scoped users == dave: readonly == Local proxy checks == No token /v2/servers: HTTP 401
Show status helper script
#!/usr/bin/env bash
# Show scoped proxy status without printing secrets.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== Containers =="
docker compose ps
echo
echo "== Environment =="
if [ -f .env ]; then
grep -E '^(DOMAIN|PUBLIC_MODE)=' .env || true
else
echo ".env not created yet. Run /opt/bl-proxy/scripts/activate.sh"
fi
echo
echo "== Scoped users =="
if [ -f manifest/users.json ]; then
python3 - <<'PY'
import json
data = json.load(open("manifest/users.json"))
for user, roles in sorted(data.get("users", {}).items()):
print(f"{user}: {', '.join(roles)}")
PY
else
echo "manifest/users.json not found"
fi
echo
echo "== Local proxy checks =="
curl -s -o /dev/null -w 'No token /v2/servers: HTTP %{http_code}\n' http://127.0.0.1:9080/v2/servers || true2. See It Working: What the Proxy Actually Does
This section applies whether you deployed with the Quickstart cloud-init payload above or with Manual Setup below. It is a standalone demonstration, with real request/response pairs captured from a live deployment, showing exactly what changes once the proxy is running.
The core idea: your tools stop calling BinaryLane directly, and start calling the proxy instead.
Without the proxy, calling the BinaryLane API directly looks like this. Anyone holding that token can do anything your account permits, with no per-user limits and no expiry:
curl -H "Authorization: Bearer <your-binarylane-api-token>" https://api.binarylane.com.au/v2/servers
With the proxy, you target a different endpoint entirely, your own proxy's address, and authenticate with a short-lived JWT the proxy issued (via make-jwt.sh), not your BinaryLane API token:
curl -H "Authorization: Bearer <JWT-from-make-jwt.sh>" http://your-proxy-address/v2/servers
The proxy validates that JWT, checks the signed-in user's role against manifest/roles.json, and only if the role allows it, forwards the request on to BinaryLane using the real API token, which the caller never sees. Here is what that looks like with real, captured results.
1. A request the role allows:
curl -H "Authorization: Bearer $ALICE_TOKEN" http://your-proxy-address/v2/servers
Real result, captured from a live deployment: HTTP 200. The request reached BinaryLane and returned your account's real server list (not reproduced here, since it contains your account's own infrastructure).
2. A request the role does not allow, blocked before it reaches BinaryLane:
curl -X POST http://your-proxy-address/v2/servers \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'Real result, captured from a live deployment:
HTTP 403
{"error":"forbidden","user":"alice","method":"POST","path":"/v2/servers","action":"servers:create","message":"role does not permit servers:create"}This never reached BinaryLane. The proxy's own policy engine rejected it.
3. An endpoint the policy does not recognise at all, fails closed the same way:
curl http://your-proxy-address/v2/unknown-policy-check \ -H "Authorization: Bearer $ALICE_TOKEN"
Real result, captured from a live deployment:
HTTP 403
{"error":"unknown_endpoint","user":"alice","method":"GET","path":"/v2/unknown-policy-check","message":"no permission rule matches this request"}4. A mutating action a different role does allow, genuinely executed against BinaryLane:
curl -X POST http://your-proxy-address/v2/servers/<server-id>/actions \
-H "Authorization: Bearer $BOB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"reboot"}'Real result, captured from an actual reboot triggered this way against a disposable test server. This is not a simulated response, the server really rebooted:
HTTP 200
{
"action": {
"id": 45749216,
"status": "in-progress",
"type": "reboot",
"resource_type": "server",
"resource_id": 642196,
"title": "Reboot",
"reason": "Your server is being restarted",
"started_at": "2026-07-24T06:15:16Z",
"completed_at": null
}
}Same endpoint, same proxy, same kind of request as example 2, but this time the calling user's role includes servers:reboot, so it was allowed through and genuinely forwarded to BinaryLane with the real API token attached server-side.
That is the entire mechanism: a different endpoint, a different (short-lived, revocable) credential, and a policy check standing between the caller and your real BinaryLane API token. For the full setup walkthrough, continue to Manual Setup below, or generate your own users and try these same calls against your own deployment - see 1.4 Create Initial Users and 3.5.8 Using the Scoped Proxy for more.
3. Manual Setup
Use the manual steps below if you do not want to use cloud-init, or if you want to understand each file that the quickstart installs.
3.1 Install Docker
On the VPS, install Docker and Docker Compose:
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER newgrp docker
Verify both are working:
docker --version docker compose version
3.2 Set Up the Firewall
In the BinaryLane management panel (mPanel), configure the external firewall on your VPS:
- Allow inbound TCP 80 and TCP 443 from any source (HTTPS proxy ports)
- Allow inbound TCP 22 (SSH) from your management IP only
Configure these rules manually in mPanel. This guide does not cover programmatic firewall setup.
3.3 Deploy the Stack
Create a working directory and project files:
mkdir -p /opt/bl-proxy/{apisix/logs,caddy,scripts,manifest}
cd /opt/bl-proxyCreate .env without putting the API token directly in your shell history:
read -rsp "BinaryLane API token: " BL_API_TOKEN echo read -rp "DNS name for this proxy, e.g. bl-proxy.example.com: " DOMAIN umask 077 printf 'BL_API_TOKEN=%s DOMAIN=%s ' "$BL_API_TOKEN" "$DOMAIN" > .env chmod 600 .env
The token is written to .env with root-only permissions. Do not paste your live API token into commands that may be saved in shell history.
Create docker-compose.yml:
services:
apisix:
image: apache/apisix:3.17.0-debian
container_name: apisix
ports:
- "127.0.0.1:9080:9080"
volumes:
- ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- ./apisix/logs:/usr/local/apisix/logs
depends_on:
- etcd
restart: unless-stopped
networks:
- apisix-net
caddy:
image: caddy:2-alpine
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
- DOMAIN=${DOMAIN:-bl-proxy.example.com}
depends_on:
- apisix
restart: unless-stopped
networks:
- apisix-net
etcd:
image: quay.io/coreos/etcd:v3.5.9
container_name: etcd
ports: []
command: >
etcd --listen-client-urls=http://0.0.0.0:2379
--advertise-client-urls=http://etcd:2379
--data-dir=/etcd-data
--enable-v2=true
volumes:
- etcd-data:/etcd-data
restart: unless-stopped
networks:
- apisix-net
volumes:
etcd-data:
caddy-data:
caddy-config:
networks:
apisix-net:
driver: bridgeCreate caddy/Caddyfile:
{$DOMAIN} {
reverse_proxy apisix:9080
encode gzip
log {
output file /data/access.log {
roll_size 50mb
roll_keep 5
}
}
}Manual Setup shows the public HTTPS/domain path. For private/IP-only mode, use a Caddyfile beginning with :80 instead of {$DOMAIN}, and only expose that listener through a VPC, VPN, bastion, SSH tunnel, or other trusted private path.Create apisix/config.yaml. Generate a unique admin key:
ADMIN_KEY=$(openssl rand -hex 32)
cat > apisix/config.yaml << EOF
deployment:
role: traditional
role_traditional:
config_provider: etcd
admin:
listen:
ip: 0.0.0.0
port: 9180
allow_admin:
- 0.0.0.0/0
admin_key:
- name: admin
key: '${ADMIN_KEY}'
role: admin
etcd:
host:
- 'http://etcd:2379'
prefix: '/apisix'
EOF
chown root:636 apisix/config.yaml apisix/logs
chmod 640 apisix/config.yaml
chmod 770 apisix/logsThe APISIX container runs as a non-root user in group 636. These ownership and permission settings let APISIX read its configuration and write logs without exposing the admin configuration more broadly.
Start the services:
docker compose up -d
Verify all three containers are running:
docker ps
You should see apisix, etcd, and caddy with status Up. After a short delay, Caddy will provision an HTTPS certificate automatically.
3.4 Configure the Proxy (Basic Mode)
Open the Show JWT helper script dropdown below and save the contents as scripts/make-jwt.sh. The setup script uses it to generate test JWTs without requiring extra Python packages.
Show JWT helper script
#!/usr/bin/env bash
# Generate a short-lived JWT for an APISIX consumer.
set -euo pipefail
USER_KEY="${1:-${USER_KEY:-}}"
USER_SECRET="${2:-${USER_SECRET:-}}"
EXP_SECONDS="${3:-3600}"
if [ -z "$USER_KEY" ] || [ -z "$USER_SECRET" ]; then
echo "Usage: $0 <user-key> <user-secret> [expiry-seconds]" >&2
exit 1
fi
python3 - "$USER_KEY" "$USER_SECRET" "$EXP_SECONDS" <<'PY'
import base64
import hashlib
import hmac
import json
import sys
import time
user, secret, exp_seconds = sys.argv[1], sys.argv[2], int(sys.argv[3])
def b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64(json.dumps({"sub": user, "exp": int(time.time()) + exp_seconds}, separators=(",", ":")).encode())
sig = b64(hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PYCreate scripts/apisix-setup.sh:
Show APISIX setup script
#!/bin/bash
# APISIX 3.17 setup for BinaryLane API proxy
# Run on the host after `docker compose up -d`
# Calls the APISIX admin API via the container's Docker network IP
#
# Required env vars:
# BL_API_TOKEN - Your BinaryLane master API token, or set in .env
# ADMIN_KEY - APISIX admin key; defaults to apisix/config.yaml
# USER_KEY - Consumer identity (e.g. "alice")
# USER_SECRET - Secret the user signs JWTs with (generate with `openssl rand -hex 32`)
#
# Usage:
# export BL_API_TOKEN="$(grep '^BL_API_TOKEN=' .env | cut -d= -f2-)"
# export USER_KEY="alice"
# export USER_SECRET="$(openssl rand -hex 32)"
# ./scripts/apisix-setup.sh
set -euo pipefail
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
BL_API_TOKEN="${BL_API_TOKEN:?Environment variable BL_API_TOKEN is required}"
ADMIN_KEY="${ADMIN_KEY:-$(python3 - <<'PY'
import re
s = open("apisix/config.yaml").read()
match = re.search(r"key: '([^']+)'", s)
if not match:
raise SystemExit("Could not read APISIX admin key from apisix/config.yaml")
print(match.group(1))
PY
)}"
USER_KEY="${USER_KEY:?Environment variable USER_KEY is required}"
USER_SECRET="${USER_SECRET:?Environment variable USER_SECRET is required}"
API_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' apisix)
ADMIN_URL="http://$API_IP:9180/apisix/admin"
echo "=== APISIX admin at $ADMIN_URL ==="
# Create upstream
echo "-- Creating upstream 'bl-api'"
curl -sf -X PUT \
-H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "roundrobin",
"scheme": "https",
"nodes": {"api.binarylane.com.au:443": 1}
}' "$ADMIN_URL/upstreams/bl-api" | jq .
# Create route (with JWT expiry enforcement)
echo "-- Creating route 'bl-api-proxy'"
curl -sf -X PUT \
-H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg token "$BL_API_TOKEN" \
'{
"uri": "/v2/*",
"methods": ["GET","POST","PUT","PATCH","DELETE"],
"upstream_id": "bl-api",
"plugins": {
"jwt-auth": {
"key_claim_name": "sub",
"claims_to_verify": ["exp"]
},
"proxy-rewrite": {
"headers": {
"Authorization": ("Bearer " + $token)
}
}
}
}'
)" "$ADMIN_URL/routes/bl-api-proxy" | jq .
# Create consumer
echo "-- Creating consumer '$USER_KEY'"
curl -sf -X PUT \
-H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg key "$USER_KEY" \
--arg secret "$USER_SECRET" \
'{
"username": $key,
"plugins": {
"jwt-auth": {
"key": $key,
"secret": $secret,
"algorithm": "HS256"
}
}
}'
)" "$ADMIN_URL/consumers/$USER_KEY" | jq .
echo ""
echo "=== Consumer '$USER_KEY' created ==="
echo ""
echo "Give the user these credentials:"
echo " Key (sub claim) : $USER_KEY"
echo " Secret : $USER_SECRET"
echo ""
echo "IMPORTANT:"
echo " - Tokens must include an 'exp' claim (APISIX will reject tokens without it)."
echo " - Generate short-lived tokens (e.g. 1 hour) and rotate regularly."
echo " - Do not store the secret in client-side code accessible to end users."
echo ""
# Generate a test JWT (1 hour expiry)
echo "--- Test JWT (expires in 1 hour) ---"
JWT="$(./scripts/make-jwt.sh "$USER_KEY" "$USER_SECRET")"
echo "$JWT"
echo ""
echo "--- Proxy test ---"
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:9080/v2/servers \
-H "Authorization: Bearer $JWT" | head -5
echo ""
echo "Done."Make it executable and create your first user:
chmod +x scripts/apisix-setup.sh export USER_KEY="alice" export USER_SECRET=$(openssl rand -hex 32) ./scripts/apisix-setup.sh
Note the consumer key and secret output. These are what you give to the user or script.
At this point, your proxy is working in basic mode. Any valid JWT user can access all BinaryLane API endpoints. If you want per-user permission scoping, proceed to 3.5 Upgrade to Scoped Access.
3.5 Upgrade to Scoped Access (Optional)
Scoped access adds role-based authorisation. Different users get different levels of API access:
- readonly - view servers, domains, invoices, metadata. No changes.
- support - readonly plus server power/reboot, password resets, backups, domain management.
- senior-support - all support actions plus resize, rebuild, network changes, server cancellation.
- billing - billing-only: account, invoices, balance, usage. No server operations.
- admin - access to all explicitly mapped gateway actions. Unknown or newly added API endpoints still fail closed until the policy is updated.
3.5.1 Create the Roles Configuration
Save this as manifest/roles.json:
Show roles configuration
cat > manifest/roles.json << 'ROLES_EOF'
{
"description": "Role definitions for the BL scoped gateway. Each role lists actions generated into the APISIX Lua policy.",
"roles": {
"readonly": {
"description": "View-only access. Can read servers, domains, keys, invoices, and metadata. Cannot modify anything.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"images:list",
"images:read",
"keys:list",
"keys:read",
"loadbalancers:list",
"loadbalancers:read",
"regions:list",
"reversenames:list",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read"
]
},
"support": {
"description": "Junior support - readonly plus server power/reboot, password resets, and backup operations. No destructive actions.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"actions:action",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"domains:create",
"domains:update",
"domains:refresh_nameservers",
"images:list",
"images:read",
"keys:list",
"keys:read",
"loadbalancers:list",
"loadbalancers:read",
"regions:list",
"reversenames:list",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"servers:poweron",
"servers:poweroff",
"servers:powercycle",
"servers:reboot",
"servers:shutdown",
"servers:passwordreset",
"servers:enablebackups",
"servers:disablebackups",
"servers:takebackup",
"servers:restore",
"servers:changethresholdalerts",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read"
]
},
"senior-support": {
"description": "Senior support - all support actions plus resize, rebuild, network changes, kernel changes, and server cancellation.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"actions:action",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"domains:create",
"domains:update",
"domains:delete",
"domains:refresh_nameservers",
"images:list",
"images:read",
"images:update",
"keys:list",
"keys:read",
"keys:create",
"keys:update",
"keys:delete",
"loadbalancers:list",
"loadbalancers:read",
"loadbalancers:create",
"loadbalancers:update",
"loadbalancers:delete",
"regions:list",
"reversenames:list",
"reversenames:create",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"servers:create",
"servers:delete",
"servers:poweron",
"servers:poweroff",
"servers:powercycle",
"servers:reboot",
"servers:shutdown",
"servers:passwordreset",
"servers:enablebackups",
"servers:disablebackups",
"servers:takebackup",
"servers:restore",
"servers:rebuild",
"servers:resize",
"servers:rename",
"servers:resizedisk",
"servers:adddisk",
"servers:deletedisk",
"servers:changebackupschedule",
"servers:changeipv6",
"servers:enableipv6",
"servers:changeoffsitebackuplocation",
"servers:changemanageoffsitebackupcopies",
"servers:changekernel",
"servers:disableselinux",
"servers:changeportblocking",
"servers:changeregion",
"servers:changenetwork",
"servers:changereversename",
"servers:changeipv6reversenameservers",
"servers:changeadvancedfeatures",
"servers:changeadvancedfirewallrules",
"servers:changethresholdalerts",
"servers:cloneusingbackup",
"servers:attachbackup",
"servers:detachbackup",
"servers:changevpcipv4",
"servers:changeseparateprivatenetworkinterface",
"servers:changesourceanddestinationcheck",
"servers:changepartner",
"servers:uncancel",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read",
"vpcs:create",
"vpcs:update",
"vpcs:delete"
]
},
"billing": {
"description": "Billing-only access. Can read account, invoices, balance, and usage data. No server operations.",
"actions": [
"accounts:list",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"servers:list",
"servers:read",
"sizes:list"
]
},
"admin": {
"description": "Full access to all explicitly mapped gateway actions. Unknown or newly added API endpoints still fail closed until the policy is updated.",
"actions": [
"accounts:list",
"actions:list",
"actions:read",
"actions:action",
"customers:list",
"customers:read",
"datausages:list",
"datausages:read",
"domains:list",
"domains:read",
"domains:create",
"domains:update",
"domains:delete",
"domains:refresh_nameservers",
"images:list",
"images:read",
"images:update",
"keys:list",
"keys:read",
"keys:create",
"keys:update",
"keys:delete",
"loadbalancers:list",
"loadbalancers:read",
"loadbalancers:create",
"loadbalancers:update",
"loadbalancers:delete",
"regions:list",
"reversenames:list",
"reversenames:create",
"samplesets:read",
"serveractions:read",
"servers:list",
"servers:read",
"servers:create",
"servers:delete",
"servers:isrunning",
"servers:ping",
"servers:uptime",
"servers:poweron",
"servers:poweroff",
"servers:powercycle",
"servers:reboot",
"servers:shutdown",
"servers:passwordreset",
"servers:enablebackups",
"servers:disablebackups",
"servers:takebackup",
"servers:restore",
"servers:rebuild",
"servers:resize",
"servers:rename",
"servers:resizedisk",
"servers:adddisk",
"servers:deletedisk",
"servers:changebackupschedule",
"servers:changeipv6",
"servers:enableipv6",
"servers:changeoffsitebackuplocation",
"servers:changemanageoffsitebackupcopies",
"servers:changekernel",
"servers:disableselinux",
"servers:changeportblocking",
"servers:changeregion",
"servers:changenetwork",
"servers:changereversename",
"servers:changeipv6reversenameservers",
"servers:changeadvancedfeatures",
"servers:changeadvancedfirewallrules",
"servers:changethresholdalerts",
"servers:cloneusingbackup",
"servers:attachbackup",
"servers:detachbackup",
"servers:changevpcipv4",
"servers:changeseparateprivatenetworkinterface",
"servers:changesourceanddestinationcheck",
"servers:changepartner",
"servers:uncancel",
"sizes:list",
"software:list",
"software:read",
"vpcs:list",
"vpcs:read",
"vpcs:create",
"vpcs:update",
"vpcs:delete"
]
}
}
}
ROLES_EOF3.5.2 Create the Policy Generator
The scoped gateway uses a generated Lua policy module. The generator script, scripts/generate-policy-lua.py, reads manifest/roles.json and writes a self-contained Lua policy that APISIX runs during the access phase.
The generated policy accounts for the full BinaryLane API surface used by this guide:
- 115 explicitly mapped BinaryLane API endpoints from the API spec
- the generic server action endpoint without a recognised action
type, which is deliberately denied with403 - all 42 server action
typevalues used byPOST /v2/servers/{id}/actions - fail-closed handling for unknown or unmapped
/v2/*paths
Open the Show policy generator dropdown below and save the contents as scripts/generate-policy-lua.py. It is collapsed because it is too large for the main article body.
Show policy generator
#!/usr/bin/env python3
"""Generate a self-contained Lua policy module from manifest/roles.json."""
import json
roles = json.load(open("manifest/roles.json"))["roles"]
ACTION_TYPE_MAP = {
"reboot": "servers:reboot", "power_cycle": "servers:powercycle",
"power_on": "servers:poweron", "power_off": "servers:poweroff",
"shutdown": "servers:shutdown", "password_reset": "servers:passwordreset",
"rebuild": "servers:rebuild", "resize": "servers:resize",
"rename": "servers:rename", "enable_ipv6": "servers:enableipv6",
"change_ipv6": "servers:changeipv6", "change_kernel": "servers:changekernel",
"disable_selinux": "servers:disableselinux",
"change_port_blocking": "servers:changeportblocking",
"change_region": "servers:changeregion", "change_network": "servers:changenetwork",
"change_reverse_name": "servers:changereversename",
"change_advanced_features": "servers:changeadvancedfeatures",
"change_advanced_firewall_rules": "servers:changeadvancedfirewallrules",
"change_threshold_alerts": "servers:changethresholdalerts",
"enable_backups": "servers:enablebackups", "disable_backups": "servers:disablebackups",
"take_backup": "servers:takebackup", "restore": "servers:restore",
"change_backup_schedule": "servers:changebackupschedule",
"change_offsite_backup_location": "servers:changeoffsitebackuplocation",
"change_manage_offsite_backup_copies": "servers:changemanageoffsitebackupcopies",
"resize_disk": "servers:resizedisk", "add_disk": "servers:adddisk",
"delete_disk": "servers:deletedisk",
"clone_using_backup": "servers:cloneusingbackup",
"attach_backup": "servers:attachbackup", "detach_backup": "servers:detachbackup",
"change_vpc_ipv4": "servers:changevpcipv4",
"change_separate_private_network_interface": "servers:changeseparateprivatenetworkinterface",
"change_source_and_destination_check": "servers:changesourceanddestinationcheck",
"change_partner": "servers:changepartner", "uncancel": "servers:uncancel",
"change_ipv6_reverse_nameservers": "servers:changeipv6reversenameservers",
"is_running": "servers:isrunning", "ping": "servers:ping", "uptime": "servers:uptime",
}
def lua_str(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def lua_table(items):
return "{\n" + "\n".join(f" {lua_str(a)}," for a in sorted(items)) + "\n }"
# =====================================================================
# 1. Role data tables
# =====================================================================
print("-- Auto-generated from manifest/roles.json")
print("-- Role → action mappings")
print("local _ROLES = {")
for role_name, role_data in sorted(roles.items()):
print(f" [{lua_str(role_name)}] = {lua_table(set(role_data['actions']))},")
print("}")
# =====================================================================
# 2. Action type map
# =====================================================================
print()
print("-- Server action type → action name")
print("local _ACTION_TYPES = {")
for k, v in sorted(ACTION_TYPE_MAP.items()):
print(f" [{lua_str(k)}] = {lua_str(v)},")
print("}")
# =====================================================================
# 3. Path resolver
# =====================================================================
print()
print("local function _resolve(method, path)")
# GET collections
print(" local _get_coll = {")
for pat, a in sorted([
("^/v2/account$", "accounts:list"),
("^/v2/actions$", "actions:list"),
("^/v2/customers/my/invoices$", "customers:list"),
("^/v2/customers/my/unpaid%-payment%-failed%-invoices$", "customers:list"),
("^/v2/customers/my/balance$", "customers:list"),
("^/v2/data_usages/current$", "datausages:list"),
("^/v2/domains$", "domains:list"),
("^/v2/domains/nameservers$", "domains:list"),
("^/v2/images$", "images:list"),
("^/v2/load_balancers$", "loadbalancers:list"),
("^/v2/load_balancers/availability$", "loadbalancers:list"),
("^/v2/account/keys$", "keys:list"),
("^/v2/regions$", "regions:list"),
("^/v2/reverse_names/ipv6$", "reversenames:list"),
("^/v2/servers$", "servers:list"),
("^/v2/servers/threshold_alerts$", "servers:list"),
("^/v2/sizes$", "sizes:list"),
("^/v2/software$", "software:list"),
("^/v2/vpcs$", "vpcs:list"),
]):
print(f" [{lua_str(pat)}] = {lua_str(a)},")
print(" }")
print(" if method == 'GET' then")
print(" for p, a in pairs(_get_coll) do")
print(" if ngx.re.find(path, p, 'jo') then return a end")
print(" end")
print(" end")
# POST creates
print()
print(" local _post_create = {")
for pat, a in sorted([
("^/v2/domains$", "domains:create"),
("^/v2/load_balancers$", "loadbalancers:create"),
("^/v2/account/keys$", "keys:create"),
("^/v2/reverse_names/ipv6$", "reversenames:create"),
("^/v2/servers$", "servers:create"),
("^/v2/vpcs$", "vpcs:create"),
("^/v2/domains/refresh_nameserver_cache$", "domains:refresh_nameservers"),
("^/v2/domains/[^/]+/records$", "domains:create"),
]):
print(f" [{lua_str(pat)}] = {lua_str(a)},")
print(" }")
print(" if method == 'POST' then")
print(" for p, a in pairs(_post_create) do")
print(" if ngx.re.find(path, p, 'jo') then return a end")
print(" end")
print(" end")
# ID patterns
print()
print(" local _id_pats = {")
for pat, a in sorted([
("^/v2/actions/[^/]+$", "actions:read"),
("^/v2/customers/my/invoices/[^/]+$", "customers:read"),
("^/v2/data_usages/[^/]+/current$", "datausages:read"),
("^/v2/domains/[^/]+$", "domains:read"),
("^/v2/domains/[^/]+/records$", "domains:read"),
("^/v2/domains/[^/]+/records/[^/]+$", "domains:read"),
("^/v2/images/[^/]+$", "images:read"),
("^/v2/load_balancers/[^/]+$", "loadbalancers:read"),
("^/v2/account/keys/[^/]+$", "keys:read"),
("^/v2/samplesets/[^/]+$", "samplesets:read"),
("^/v2/samplesets/[^/]+/latest$", "samplesets:read"),
("^/v2/servers/[^/]+$", "servers:read"),
("^/v2/servers/[^/]+/actions$", "serveractions:read"),
("^/v2/servers/[^/]+/actions/[^/]+$", "servers:read"),
("^/v2/servers/[^/]+/advanced_firewall_rules$", "servers:read"),
("^/v2/servers/[^/]+/available_advanced_features$", "servers:read"),
("^/v2/servers/[^/]+/backups$", "servers:read"),
("^/v2/servers/[^/]+/console$", "servers:read"),
("^/v2/servers/[^/]+/kernels$", "servers:read"),
("^/v2/servers/[^/]+/snapshots$", "servers:read"),
("^/v2/servers/[^/]+/software$", "servers:read"),
("^/v2/servers/[^/]+/threshold_alerts$", "servers:read"),
("^/v2/servers/[^/]+/user_data$", "servers:read"),
("^/v2/software/[^/]+$", "software:read"),
("^/v2/software/operating_system/[^/]+$", "software:read"),
("^/v2/vpcs/[^/]+$", "vpcs:read"),
("^/v2/vpcs/[^/]+/members$", "vpcs:read"),
]):
print(f" [{lua_str(pat)}] = {lua_str(a)},")
print(" }")
print(" for p, a in pairs(_id_pats) do")
print(" if ngx.re.find(path, p, 'jo') then")
print(" if method == 'GET' then return a")
print(" elseif method == 'PUT' or method == 'PATCH' then")
print(" return a:gsub(':read$', ':update')")
print(" elseif method == 'DELETE' then")
print(" return a:gsub(':read$', ':delete')")
print(" end")
print(" end")
print(" end")
# LB sub-resources
print()
print(" if ngx.re.find(path, [[^/v2/load_balancers/[^/]+/forwarding_rules$]], 'jo') then")
print(" if method == 'POST' then return 'loadbalancers:create'")
print(" elseif method == 'DELETE' then return 'loadbalancers:delete'")
print(" end")
print(" end")
print(" if ngx.re.find(path, [[^/v2/load_balancers/[^/]+/servers$]], 'jo') then")
print(" if method == 'POST' then return 'loadbalancers:create'")
print(" elseif method == 'DELETE' then return 'loadbalancers:delete'")
print(" end")
print(" end")
print(" if method == 'POST' and ngx.re.find(path, [[^/v2/actions/[^/]+/proceed$]], 'jo') then")
print(" return 'actions:action'")
print(" end")
print(" if method == 'GET' and ngx.re.find(path, [[^/v2/images/[^/]+/download$]], 'jo') then")
print(" return 'images:read'")
print(" end")
print(" if method == 'POST' and ngx.re.find(path, [[^/v2/servers/[^/]+/backups$]], 'jo') then")
print(" return 'servers:create'")
print(" end")
print()
print(" return nil")
print("end")
# =====================================================================
# 4. Main policy function
# =====================================================================
print()
print("local cjson = require 'cjson'")
print("return function(conf, ctx)")
print(" local username = (ctx.consumer or {}).username")
print(" if not username then")
print(" return 403, {error='missing_user', message='Authenticated consumer not available'}")
print(" end")
print()
print(" -- Resolve roles from conf.users[username]")
print(" local user_roles = {}")
print(" if conf.users and conf.users[username] then")
print(" for _, r in ipairs(conf.users[username]) do")
print(" user_roles[r] = true")
print(" end")
print(" end")
print(" if next(user_roles) == nil then")
print(" return 403, {error='forbidden', user=username, message='no roles assigned'}")
print(" end")
print()
print(" -- Collect allowed actions across all roles")
print(" local allowed = {}")
print(" for role, _ in pairs(user_roles) do")
print(" local actions = _ROLES[role]")
print(" if actions then")
print(" for _, a in ipairs(actions) do")
print(" allowed[a] = true")
print(" end")
print(" end")
print(" end")
print()
print(" -- Determine requested action")
print(" local method = ngx.req.get_method()")
print(" local path = ngx.var.uri")
print(" local action = nil")
print()
print(" -- Special: POST /v2/servers/{id}/actions → parse JSON body for 'type'")
print(" if method == 'POST' and ngx.re.find(path, [[^/v2/servers/[^/]+/actions$]], 'jo') then")
print(" ngx.req.read_body()")
print(" local body = ngx.req.get_body_data()")
print(" if body then")
print(" local ok, tbl = pcall(cjson.decode, body)")
print(" if ok and tbl.type then")
print(" action = _ACTION_TYPES[tbl.type]")
print(" end")
print(" end")
print(" else")
print(" action = _resolve(method, path)")
print(" end")
print()
print(" -- Unknown or unmapped /v2 path: fail closed so new API endpoints do not bypass role checks.")
print(" if not action then")
print(" return 403, {")
print(" error = 'unknown_endpoint',")
print(" user = username,")
print(" method = method,")
print(" path = path,")
print(" message = 'no permission rule matches this request',")
print(" }")
print(" end")
print()
print(" -- Enforce")
print(" if not allowed[action] then")
print(" return 403, {")
print(" error = 'forbidden',")
print(" user = username,")
print(" method = method,")
print(" path = path,")
print(" action = action,")
print(" message = 'role does not permit ' .. action,")
print(" }")
print(" end")
print("end")The generator reads JSON only, but the supporting project scripts may use YAML tooling. If your system does not already have PyYAML installed, install it with:
pip3 install pyyaml
You can confirm the generator runs with:
python3 scripts/generate-policy-lua.py > /tmp/scoped-policy.lua head /tmp/scoped-policy.lua
3.5.3 Create the Deployment Script
Create scripts/deploy-scoped.sh.
Open the Show scoped deployment script dropdown below and save the contents as scripts/deploy-scoped.sh:
Show scoped deployment script
#!/usr/bin/env bash
# Scoped access deployment - generates Lua policy from roles.json
# and deploys via serverless-pre-function inline approach.
# Run from /opt/bl-proxy after the Docker stack is active.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
BL_API_TOKEN="${BL_API_TOKEN:?Set BL_API_TOKEN in the environment or .env}"
API_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' apisix)
ADMIN_URL="http://$API_IP:9180/apisix/admin"
ADMIN_KEY=$(python3 -c "
import re
s = open('apisix/config.yaml').read()
print(re.search(r\"key: '([^']+)'\", s).group(1))
")
# Step 1: Generate Lua policy source from roles.json
python3 scripts/generate-policy-lua.py > /tmp/scoped-policy.lua
echo "[+] Generated /tmp/scoped-policy.lua ($(wc -l < /tmp/scoped-policy.lua) lines)"
# Step 2: Build route JSON with inline Lua + user-role config
python3 - "$BL_API_TOKEN" <<'PY' > /tmp/route-scoped.json
import json, sys
token = sys.argv[1]
lua = open("/tmp/scoped-policy.lua").read()
try:
with open("manifest/users.json") as f:
users = json.load(f)["users"]
except FileNotFoundError:
raise SystemExit("manifest/users.json is missing. Create it before deploying scoped mode.")
payload = {
"uri": "/v2/*",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"upstream_id": "bl-api",
"plugins": {
"jwt-auth": {
"key_claim_name": "sub",
"claims_to_verify": ["exp"],
},
"serverless-pre-function": {
"phase": "access",
"functions": [lua],
"policy": {}, # reserved for future use
"users": users,
},
"proxy-rewrite": {
"headers": {"Authorization": "Bearer " + token},
},
},
}
print(json.dumps(payload))
PY
# Step 3: Deploy route
curl -sf -X PUT \
-H "X-API-KEY: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d @/tmp/route-scoped.json \
"$ADMIN_URL/routes/bl-api-proxy" >/dev/null
echo "[+] Route bl-api-proxy updated"
echo ""
echo "=== Scoped access deployment complete ==="
echo "Users loaded from manifest/users.json"
echo "Run scripts/test-scoped-policy.sh to verify"# paste the scoped deployment script contents into this file nano scripts/deploy-scoped.sh
The deployment script reads user-role assignments from manifest/users.json:
{
"users": {
"alice": ["readonly"],
"bob": ["support"],
"charlie": ["admin"]
}
}This maps each APISIX JWT consumer username to one or more roles from manifest/roles.json.
To change who gets which access, edit manifest/users.json, then re-run scripts/deploy-scoped.sh. Consumer secrets are registered separately through scripts/apisix-setup.sh or the helper script included in the quickstart.
Running scripts/deploy-scoped.sh overwrites the basic-mode /v2/* route with the scoped-mode route. The route still proxies to the BinaryLane API with your master API token, but APISIX now checks the user's role before forwarding the request.
3.5.4 Register Your Users
For Manual Setup, save the add-user helper before creating scoped users. The helper registers the APISIX consumer, updates manifest/users.json, redeploys scoped mode, and writes secrets/<username>.txt. The secret files are required by the built-in verifier in section 3.5.6.
Show add-user helper script for Manual Setup
#!/usr/bin/env bash
# Add or update a scoped API user.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
if [ "${PUBLIC_MODE:-}" = "private-http" ]; then
PROXY_URL="${PROXY_URL:-http://<vps-private-or-public-ip>}"
elif [ -n "${DOMAIN:-}" ]; then
PROXY_URL="${PROXY_URL:-https://$DOMAIN}"
else
PROXY_URL="${PROXY_URL:-https://<proxy-hostname>}"
fi
USER_KEY="${1:-}"
ROLE="${2:-}"
USER_SECRET="${3:-}"
if [ -z "$USER_KEY" ]; then
read -rp "Username / JWT sub claim: " USER_KEY
fi
if [ -z "$ROLE" ]; then
echo "Available roles: readonly, support, senior-support, billing, admin"
read -rp "Role for $USER_KEY: " ROLE
fi
case "$ROLE" in
readonly|support|senior-support|billing|admin) ;;
*)
echo "Unsupported role: $ROLE" >&2
exit 1
;;
esac
if [ -z "$USER_SECRET" ]; then
USER_SECRET="$(openssl rand -hex 32)"
fi
existing_user=0
if [ -f manifest/users.json ] && python3 - "$USER_KEY" <<'PY'
import json
import sys
from pathlib import Path
try:
data = json.loads(Path("manifest/users.json").read_text())
except Exception:
raise SystemExit(1)
raise SystemExit(0 if sys.argv[1] in data.get("users", {}) else 1)
PY
then
existing_user=1
fi
export USER_KEY USER_SECRET
./scripts/apisix-setup.sh >/tmp/bl-proxy-add-user.out
python3 - "$USER_KEY" "$ROLE" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path("manifest/users.json")
data = json.loads(path.read_text()) if path.exists() else {"users": {}}
data.setdefault("users", {})[sys.argv[1]] = [sys.argv[2]]
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
PY
./scripts/deploy-scoped.sh >/tmp/bl-proxy-deploy-scoped.out
mkdir -p secrets
chmod 700 secrets
{
echo "User: $USER_KEY"
echo "Role: $ROLE"
echo "Secret: $USER_SECRET"
echo "Created: $(date -Is)"
echo
echo "Generate a JWT:"
echo " /opt/bl-proxy/scripts/make-jwt.sh '$USER_KEY' '$USER_SECRET'"
echo
echo "Authenticate to the proxy:"
echo " TOKEN=\$(/opt/bl-proxy/scripts/make-jwt.sh '$USER_KEY' '$USER_SECRET')"
echo " curl -H \"Authorization: Bearer \$TOKEN\" $PROXY_URL/v2/servers"
} > "secrets/${USER_KEY}.txt"
chmod 600 "secrets/${USER_KEY}.txt"
if [ "$existing_user" = "1" ]; then
echo "User '$USER_KEY' updated with role '$ROLE'."
echo "The user's JWT secret has been rotated; old JWTs for this user are now invalid."
else
echo "User '$USER_KEY' registered with role '$ROLE'."
fi
echo "Secret saved to /opt/bl-proxy/secrets/${USER_KEY}.txt"
echo "Give the user their key, secret, and the JWT generation example from that file."
echo
echo "Add another user later with:"
echo " sudo /opt/bl-proxy/scripts/add-user.sh <username> <role>"
echo
echo "Available roles: readonly, support, senior-support, billing, admin"# paste the add-user helper script contents into this file nano scripts/add-user.sh chmod +x scripts/add-user.sh
Create the sample users used by the verification steps:
./scripts/add-user.sh alice readonly ./scripts/add-user.sh bob support ./scripts/add-user.sh charlie admin
To add another user later, run the same helper with the username and role you want:
./scripts/add-user.sh dave readonly
Record each generated secret somewhere secure. The user or calling system needs it to sign JWTs for that consumer.
3.5.5 Deploy Scoped Mode
Make the deployment script executable and run it:
chmod +x scripts/deploy-scoped.sh ./scripts/deploy-scoped.sh
If the script completes successfully, APISIX is now enforcing role-based access for the /v2/* route.
3.5.6 Test Scoped Access
Generate short-lived JWTs for your test users from the secret files created by add-user.sh:
ALICE_SECRET=$(awk -F': ' '/^Secret: / {print $2; exit}' secrets/alice.txt)
BOB_SECRET=$(awk -F': ' '/^Secret: / {print $2; exit}' secrets/bob.txt)
CHARLIE_SECRET=$(awk -F': ' '/^Secret: / {print $2; exit}' secrets/charlie.txt)
make_jwt() {
local user=$1
local secret=$2
python3 - "$user" "$secret" <<'PY'
import base64
import hashlib
import hmac
import json
import sys
import time
user, secret = sys.argv[1], sys.argv[2]
def b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64(json.dumps({"sub": user, "exp": int(time.time()) + 3600}, separators=(",", ":")).encode())
sig = b64(hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
}
ALICE_TOKEN=$(make_jwt alice "$ALICE_SECRET")
BOB_TOKEN=$(make_jwt bob "$BOB_SECRET")
CHARLIE_TOKEN=$(make_jwt charlie "$CHARLIE_SECRET")If you store secrets somewhere else, replace the three *_SECRET values with the real secrets you registered for each APISIX consumer.
Readonly user can list servers:
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:9080/v2/servers \
-H "Authorization: Bearer $ALICE_TOKEN"Real response, captured from a live deployment: HTTP 200. The body lists your account's own servers - not shown here since real output contains your account's infrastructure details.
Readonly user cannot create or delete servers:
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:9080/v2/servers \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
curl -s -w "\nHTTP %{http_code}\n" -X DELETE http://127.0.0.1:9080/v2/servers/12345 \
-H "Authorization: Bearer $ALICE_TOKEN"Both should return 403. Real responses, captured from a live deployment:
HTTP 403
{"error":"forbidden","user":"alice","method":"POST","path":"/v2/servers","action":"servers:create","message":"role does not permit servers:create"}
HTTP 403
{"error":"forbidden","user":"alice","method":"DELETE","path":"/v2/servers/12345","action":"servers:delete","message":"role does not permit servers:delete"}Support user can reboot, if their role includes servers:reboot.
This is a live server action. Use a real test server ID and only run this against a server where a reboot is acceptable.
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:9080/v2/servers/12345/actions \
-H "Authorization: Bearer $BOB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"reboot"}'That request is allowed by the gateway and forwarded to BinaryLane. Real response, captured from a live reboot against a disposable test server:
HTTP 200
{
"action": {
"id": 45749216,
"status": "in-progress",
"type": "reboot",
"resource_type": "server",
"resource_id": 642196,
"title": "Reboot",
"reason": "Your server is being restarted",
"started_at": "2026-07-24T06:15:16Z",
"completed_at": null
}
}The resource_id above is a disposable test server used to capture this example - substitute your own real, non-critical server ID.
Support user cannot rebuild:
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:9080/v2/servers/12345/actions \
-H "Authorization: Bearer $BOB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"rebuild"}'This should return 403. Real response, captured from a live deployment:
HTTP 403
{"error":"forbidden","user":"bob","method":"POST","path":"/v2/servers/12345/actions","action":"servers:rebuild","message":"role does not permit servers:rebuild"}Admin user can access mapped admin actions:
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:9080/v2/servers \
-H "Authorization: Bearer $CHARLIE_TOKEN"Real response: HTTP 200 (again, the body lists your account's own servers).
Unknown endpoints fail closed:
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:9080/v2/unknown-policy-check \
-H "Authorization: Bearer $ALICE_TOKEN"This should return 403, not pass through to the upstream API. Real response, captured from a live deployment:
HTTP 403
{"error":"unknown_endpoint","user":"alice","method":"GET","path":"/v2/unknown-policy-check","message":"no permission rule matches this request"}3.5.7 Available Actions Reference
The complete list of explicitly allowed actions is available in the policy generator source and manifest/roles.json. The current policy maps 115 API endpoints directly and deliberately denies the generic server action endpoint when the request body does not contain a recognised action type.
For day-to-day role changes, use the structure in manifest/roles.json as the reference:
{
"roles": {
"readonly": {
"description": "View-only access.",
"actions": [
"servers:list",
"servers:read"
]
}
}
}Each role has a description and an actions list. Add or remove action names from the relevant role, then run scripts/deploy-scoped.sh again.
3.5.8 Using the Scoped Proxy
Once scoped mode is deployed, this is what day-to-day use looks like. The examples above focused on proving the role boundaries hold - these are ordinary calls a user makes once everything is set up.
Generate a fresh JWT whenever you need one. Tokens are short-lived (1 hour by default), so generate a new one each session rather than reusing an old one:
TOKEN=$(/opt/bl-proxy/scripts/make-jwt.sh alice "$ALICE_SECRET") curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:9080/v2/servers
The proxy works the same way for any mapped BinaryLane endpoint, not just /v2/servers. For example, listing available regions:
curl -s -w "\nHTTP %{http_code}\n" http://127.0.0.1:9080/v2/regions \
-H "Authorization: Bearer $ALICE_TOKEN"Real response, captured from a live deployment (this endpoint returns the same public catalog data for every account, so it is shown in full):
HTTP 200
{"regions":[{"slug":"syd","name":"Sydney","sizes":["std-min","std-1vcpu","std-2vcpu", ...],"available":true,"features":["backups","ipv6"],"name_servers":["43.229.61.2","43.229.62.2","8.8.8.8"]},{"slug":"mel","name":"Melbourne", ...}, ...]}Or listing available server sizes:
curl -s http://127.0.0.1:9080/v2/sizes \ -H "Authorization: Bearer $ALICE_TOKEN"
Real response (abbreviated to the first size for length):
HTTP 200
{
"sizes": [
{
"slug": "std-min",
"available": true,
"regions": ["mel", "bne", "syd", "per", "sin", "adl"],
"price_monthly": 4.9,
"price_hourly": 0.006805555555555555,
"disk": 20,
"memory": 1024,
"vcpus": 1
}
],
"meta": { "total": 21 }
}A few things worth keeping in mind for everyday use:
- Endpoints and actions your role does not cover return
403before the request ever reaches BinaryLane - see 3.5.7 Available Actions Reference for the full list of what each role can do. - An expired token returns
401, not403. Generate a new one withmake-jwt.shand retry. - Always send the token in the
Authorizationheader, never as a query parameter. - If a user's role needs to change, or their secret needs to be rotated, re-run
add-user.sh <username> <role>- see Security Notes.
4. Security Notes
Scoped mode improves on the basic JWT proxy by adding role-based scoping. A valid JWT no longer means full access to the master BinaryLane API token. The user's APISIX consumer must also be mapped to a role, and that role must allow the requested action.
Keep these points in mind:
- Scoped mode adds role-based scoping, so not all users get full access.
- Unknown endpoints fail closed with
403rather than passing through unchecked. - Do not expose the APISIX admin port or etcd externally. Keep them bound to the private Docker network or localhost-only administration paths.
- The master BinaryLane API token is stored only in the APISIX route configuration via
proxy-rewrite. - Use short-lived JWTs with an
expclaim. - Rotate JWT consumer secrets regularly and immediately when a user no longer needs access.
- Send JWTs only in the
Authorizationheader. Do not put JWTs in query strings, because request paths can be written to access logs. - Treat
adminas highly privileged, but not as an unrestricted bypass. Unknown or newly added API endpoints still return403until the gateway policy is updated.
5. Troubleshooting
HTTP 403 no permission rule matches this request
The endpoint is not mapped in the policy. This is the fail-closed behaviour. Check whether the request path and method are covered by the policy generator.
HTTP 403 role does not permit <action>
The user's role does not include the requested action. Check manifest/roles.json, add the action to the appropriate role if it should be allowed, then redeploy.
HTTP 403 no roles assigned
The user is not listed in manifest/users.json, or the user has an empty role list. Add the user with scripts/add-user.sh, or edit manifest/users.json and redeploy.
Changes not taking effect
After editing manifest/roles.json or manifest/users.json, re-run:
./scripts/deploy-scoped.sh
Also confirm you are testing with a newly generated JWT if you changed the consumer key or secret.
Changing access mode
Re-run sudo /opt/bl-proxy/scripts/activate.sh to switch between public HTTPS and private/IP-only mode. If existing users are detected, the script asks whether to keep them. Choose the default Y unless you intentionally want to reset user access and recreate users.
6. Next Steps / Future
Useful future enhancements would be:
- structured audit logging for allowed and denied API requests
- more granular custom roles beyond the five default roles
- dynamic role changes without redeploying the APISIX route
