#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 < 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://" 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 " 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 <.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 '' Use the proxy: TOKEN=\$(sudo /opt/bl-proxy/scripts/make-jwt.sh '') 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 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://}" elif [ -n "${DOMAIN:-}" ]; then PROXY_URL="${PROXY_URL:-https://$DOMAIN}" else PROXY_URL="${PROXY_URL:-https://}" 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 " 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 [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=. 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"