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-1vcpu or 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




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


1.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 access
  • support - read-only plus selected support operations
  • senior-support - broader operational access
  • billing - account, invoice, balance, and usage access
  • admin - 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 || true


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

Create .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: bridge

Create 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/logs

The 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}")
PY


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


3.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 with 403
  • all 42 server action type values used by POST /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 403 before 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, not 403. Generate a new one with make-jwt.sh and retry.
  • Always send the token in the Authorization header, 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 403 rather 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 exp claim.
  • Rotate JWT consumer secrets regularly and immediately when a user no longer needs access.
  • Send JWTs only in the Authorization header. Do not put JWTs in query strings, because request paths can be written to access logs.
  • Treat admin as highly privileged, but not as an unrestricted bypass. Unknown or newly added API endpoints still return 403 until 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