--- name: use-bnna-api version: 1.0.0 description: Manage LXC containers, bunches, accounts, and DNS via the bnna REST API. Use when creating, listing, or destroying containers or assigning public domains — instead of calling Proxmox directly. tags: [bnna, api, lxc, dns, accounts, bunches, proxmox] --- # Use bnna API Manage LXC containers and their public domains through the bnna API service. Use this instead of calling the Proxmox API directly. **See `LOCAL.md` for the real service URL, admin token, account slugs, and provider IDs.** --- ## Response Format JSON responses are always wrapped in an envelope — never bare arrays or objects: ```json { "result": , "timings": [...] } // success { "error": "message", "details": [...], "timings": [...] } // error ``` `details` is an optional array of additional context strings; omitted when empty. TSV/CSV responses stream rows directly; timing data is in the `X-Timings` response header as a JSON array. To extract just the payload with `jq`: ```sh curl ... | jq '.result' ``` --- ## Auth All requests use a bearer token: ```sh BNNA_HOST="https://" BNNA_TOKEN="" AUTH="Authorization: Bearer ${BNNA_TOKEN}" ``` --- ## `?confirm` for mutating calls Every mutating endpoint (DELETE, plus POST/PUT that drop data, reset credentials, force-stop workloads, etc.) requires `?confirm` or `?confirm=true`. Without it the server returns **422 Unprocessable Entity** and performs no action. Any other `confirm` value (`false`, `1`, `yes`) returns **400 Bad Request**. ```sh # Rejected — 422 curl -sSL -X DELETE -H "$AUTH" "$BNNA_HOST/api/instances/$VMID" # Performs the delete — 200 curl -sSL -X DELETE -H "$AUTH" "$BNNA_HOST/api/instances/$VMID?confirm" ``` To preview before mutating: `GET` the resource first, inspect, then re-issue with `?confirm`. The server does not build a preview body. ```sh # Preview curl -sSL -H "$AUTH" "$BNNA_HOST/api/instances/$VMID" # Commit curl -sSL -X DELETE -H "$AUTH" "$BNNA_HOST/api/instances/$VMID?confirm" ``` For multi-resource operations (e.g. account GC), use the matching inventory endpoint as the preview: `GET /api/accounts/$SLUG/inventory`. See `docs/2026-04-09_delete-dry-run-default.md` for the ADR. --- ## Nodes ```sh # List nodes across all clusters curl -sSL -H "$AUTH" "$BNNA_HOST/api/nodes" # TSV: cluster, node, status, max_cpu, cpu_usage, max_mem_mb, ... ``` --- ## Accounts & Bunches ```sh # List accounts curl -sSL -H "$AUTH" "$BNNA_HOST/api/accounts" # List bunches (resource pools) for quota and membership curl -sSL -H "$AUTH" "$BNNA_HOST/api/bunches" ``` ### Create an account (`POST /api/accounts`) ```sh curl -sSL -X POST -H "$AUTH" \ -H "Content-Type: application/json" \ "$BNNA_HOST/api/accounts" \ --data '{ "account": "slug", "displayName": "Display Name", "userId": "slug@pve", "createToken": true }' # Returns: accountSlug, groupId, poolActive, poolOffline, # networkZone, networkVNet, tokenFullId, tokenSecret ``` **Cross-cluster VLAN deduplication:** before assigning a network, the API calls `ListSDNVNets` on every cluster in `clusters.tsv` (including read-only prod). Any VLAN tag already in use on any cluster is skipped. This means `clusters.tsv` **must** include the prod cluster or conflicting VLANs (e.g. `1105` = dashvnet on prod) may be assigned to a dev account. --- ## Create an LXC Container The API handles node selection automatically — tenants don't specify a node. ```sh # POST to /api/lxc (node is selected from cluster default_node) # Body parameters: # - pool = bunch slug (required, e.g. "paperos-active") # - hostname = container hostname (required) # - vmid = optional; must follow 7-digit scheme if provided: # {4-digit-prefix}{3-digit-index} where the last 3 digits # map to an IPv4 address in the account's subnet # e.g. prefix 1104, index 231 → vmid 1104231 → IP 10.11.4.231 # - ostemplate = template volid (required) # - cores, memory, rootfs, net0, etc. — standard Proxmox LXC params # - unprivileged=1, onboot=1, start=1 are set automatically # - The API enforces quota limits — returns 422 if exceeded curl -sSL -X POST -H "$AUTH" \ -H "Content-Type: application/x-www-form-urlencoded" \ "$BNNA_HOST/api/lxc" \ --data-urlencode "vmid=1104232" \ --data-urlencode "hostname=myapp" \ --data-urlencode "ostemplate=zpool-lab-slc1-paperos:vztmpl/alpine-3.23-bnna_20260116_amd64.tar.zst" \ --data-urlencode "pool=paperos-active" \ --data-urlencode "cores=2" \ --data-urlencode "memory=2048" \ --data-urlencode "rootfs=zpool-lab-slc1-paperos:8" \ --data-urlencode "net0=name=eth0,bridge=v1104,ip=10.11.4.232/24,gw=10.11.4.1" \ --data-urlencode "unprivileged=1" \ --data-urlencode "start=1" # Returns: {"upid":"UPID:pve1:..."} ``` **VMID scheme for paperos on dev cluster:** - Prefix: `1104` (VLAN 1104) - IP range: `10.11.4.{index}/24`, gateway `10.11.4.1` - Bridge/vnet: `vlan1104` - Next available VMID: check existing with `GET /api/instances` filtered by bunch --- ## Instance Lifecycle ```sh VMID=1104232 # Start curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/instances/$VMID/start" # Graceful shutdown curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/instances/$VMID/shutdown" # Force stop curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/instances/$VMID/stop" # Reboot (guest-level) curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/instances/$VMID/reboot" # Cold reboot (hard stop + start, reloads AppArmor profiles) curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/instances/$VMID/reboot?cold=true" # Destroy (stop first) — requires ?confirm curl -sSL -X DELETE -H "$AUTH" "$BNNA_HOST/api/instances/$VMID?confirm" # Add &purge=1 to also remove backups/snapshots ``` --- ## List Instances ```sh # All instances across all bunches curl -sSL -H "$AUTH" "$BNNA_HOST/api/instances" # Filter by bunch slug via query param curl -sSL -H "$AUTH" "$BNNA_HOST/api/instances?pool=paperos-active" ``` --- ## Assign a Public Domain (CNAME) After creating a container, point a public hostname at it: ```sh VMID=1104232 PROVIDER_ID="" # from GET /api/accounts/{slug}/dns/providers LEAF="myapp" # becomes myapp.example.com curl -sSL -X POST -H "$AUTH" \ -H "Content-Type: application/json" \ "$BNNA_HOST/api/subdomains" \ --data "{ \"providerId\": \"$PROVIDER_ID\", \"leaf\": \"$LEAF\", \"vmid\": $VMID }" # Returns: {"domainId":"...","fqdn":"myapp.example.com","target":"tls-10-11-4-232.a.bnna.net"} # TLS is auto-provisioned by the router once DNS propagates. ``` > **Always use this endpoint — never call the DNS provider's API (Cloudflare, name.com) > directly.** Direct API calls have been observed to show the record in the dashboard > but not propagate to authoritative nameservers. The libdns/domainpanel path used here > propagates correctly. > **`DIRECT_IP_DOMAIN` must be set** in the bnna env file for vmid-based targeting. > Check `LOCAL.md`. Without it, use the explicit `"target"` field instead. List/remove domains for an instance: ```sh curl -sSL -H "$AUTH" "$BNNA_HOST/api/instances/$VMID/domains" curl -sSL -X DELETE -H "$AUTH" "$BNNA_HOST/api/subdomains/$PROVIDER_ID/$LEAF?confirm" ``` --- ## Account Lifecycle ```sh SLUG="myaccount" # Get account details curl -sSL -H "$AUTH" "$BNNA_HOST/api/accounts/$SLUG" # Suspend (read/delete pass; writes → 403) — requires ?confirm curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/accounts/$SLUG/suspend?confirm" # Disable (harder lockout) — requires ?confirm curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/accounts/$SLUG/disable?confirm" # Re-enable curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/accounts/$SLUG/enable" # Sync ACLs (idempotent — safe to run after drift) curl -sSL -X PUT -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/accounts/$SLUG/acls" --data '{}' # Garbage-collect a disabled account (admin-only) — requires ?confirm # Preview first with GET /api/accounts/$SLUG/inventory curl -sSL -X DELETE -H "$AUTH" "$BNNA_HOST/api/accounts/$SLUG?confirm" ``` --- ## Networks ```sh # List all SDN networks (with assignment status) curl -sSL -H "$AUTH" "$BNNA_HOST/api/networks" # Pre-allocate a single network record (admin) # The zone and vnet must already exist in Proxmox (created out-of-band). curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/networks/prepare" \ --data '{"zone":"z1109","vnet":"v1109","vlanTag":1109}' # Allocate N new VLAN networks (creates SDN zones+vnets, registers in DB) curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/networks/allocate" --data '{"n": 5}' # Returns: nullZone status + list of newly created networks # Note: SDN changes are NOT auto-applied. Call POST /api/networks/apply separately. # Check pending SDN changes curl -sSL -H "$AUTH" "$BNNA_HOST/api/networks/pending" # Apply pending SDN changes (admin) curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/networks/apply" ``` --- ## Admin — Bunch Sync Detect Proxmox pools with no corresponding DB bunch record and create them: ```sh curl -sSL -X POST -H "$AUTH" "$BNNA_HOST/api/platform/bunches/sync" # Returns: {"result":{"poolsTotal":12,"bunchesBefore":8,"created":4,"slugs":["pool-a","pool-b",...]}} ``` Existing bunches are NOT modified (ON CONFLICT DO NOTHING). Safe to run repeatedly. --- ## Admin — Assign Network to Account Assign a pre-allocated SDN network to an existing account that doesn't have one: ```sh # First find an unassigned network curl -sSL -H "$AUTH" "$BNNA_HOST/api/networks" | jq '.result[] | select(.accountSlug == "")' # Assign it curl -sSL -X PUT -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/platform/accounts/{slug}/network" \ --data '{"networkId": ""}' # Returns the updated account row. ACLs are auto-synced for SDN zone access. ``` The network must be unassigned. Returns 409 Conflict if already assigned to another account. --- ## Admin — Database Provisioning ### Register a Postgres instance (one-time setup) ```sh curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/platform/pg-instances" \ --data '{ "host": "172.24.0.21", "port": 15432, "adminDsn": "postgres://bnna_admin:@172.24.0.21:15432/postgres?sslmode=require", "externalHost": "tls-10-11-10-21.a.bnna.net", "externalPort": 443 }' ``` ### Create a tenant database ```sh curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/accounts/{slug}/databases" \ --data '{"prefix": "myapp"}' # Returns: db_name, password (once!), dsn (internal), dsn_external (via TLS router) ``` The database name is `{prefix}_{random_suffix}`. A role with the same name is created in the `remote_users` group. The password is shown ONCE -- save it immediately. ### Reset a database password Requires `?confirm` — the old password is gone the moment you call it. ```sh curl -sSL -X POST -H "$AUTH" \ "$BNNA_HOST/api/accounts/{slug}/databases/{name}/reset-password?confirm" # Returns: new password and updated DSNs ``` ### List tenant databases ```sh curl -sSL -H "$AUTH" "$BNNA_HOST/api/accounts/{slug}/databases" ``` --- ## Admin — Template Build & Upload ### Automated build + upload ```sh BNNA_TOKEN="" sh scripts/build-template.sh 3.23 # 1. SSHes to bnna-builder, runs /opt/bnna-customizers/build-alpine-bnna 3.23 # 2. SCPs the .tar.zst artifact to local ./tmp.d/ # 3. Uploads via POST /api/platform/storage/{storage}/upload (broadcast) ``` ### Manual upload via API ```sh # Broadcast to all nodes with this storage curl -sSL -X POST -H "$AUTH" \ -F "content=vztmpl" \ -F "filename=@alpine-3.23-bnna_20260116_amd64.tar.zst" \ "$BNNA_HOST/api/platform/storage/slc1-smb1-shared/upload" # Upload to specific nodes only (e.g. local storage on pve1) curl -sSL -X POST -H "$AUTH" \ -F "content=vztmpl" \ -F "filename=@alpine-3.23-bnna_20260116_amd64.tar.zst" \ "$BNNA_HOST/api/platform/storage/local/upload?nodes=pve1" ``` ### Download from URL ```sh # Broadcast to all nodes curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/platform/storage/local/download-url" \ --data '{"url":"https://.../template.tar.zst","filename":"template.tar.zst","content":"vztmpl"}' # Download to specific nodes only curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/platform/storage/local/download-url?nodes=pve1,pve2" \ --data '{"url":"https://.../template.tar.zst","filename":"template.tar.zst","content":"vztmpl"}' ``` --- ## Admin — Doctor Check and fix cluster prerequisites (null zone, template-users group, custom roles): ```sh # Dry-run check curl -sSL -H "$AUTH" "$BNNA_HOST/api/platform/doctor" # Fix curl -sSL -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$BNNA_HOST/api/platform/doctor" --data '{"templateStorages":["local"]}' ``` --- ## Node & Datacenter Notes Update notes visible in the Proxmox GUI Summary tab. Use after maintenance, kernel upgrades, or any on-cluster changes. ```sh # Read node notes curl -sSLk -H "$AUTH" "$BNNA_HOST/api2/json/nodes/pve1/config" | jq -r '.data.description' # Set node notes (markdown supported) curl -sSLk -X PUT -H "$AUTH" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "description=# pve1 (bn1) — Dev Cluster Kernel: 6.17.13-2-pve PVE: 9.1.6 ## Recent Changes - 2026-03-31: Kernel update" \ "$BNNA_HOST/api2/json/nodes/pve1/config" # Read datacenter notes curl -sSLk -H "$AUTH" "$BNNA_HOST/api2/json/cluster/options" | jq -r '.data.description' # Set datacenter notes curl -sSLk -X PUT -H "$AUTH" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "description=# pvec-slc1-dev — BNNA Dev Cluster Managed by bnna" \ "$BNNA_HOST/api2/json/cluster/options" ``` > **Convention:** Always update node notes after kernel upgrades or config changes. > Also log the change in `m.bnna.net` `CHANGELOG.md`. --- ## Proxmox API Pass-Through Any Proxmox API call can be proxied through `/api2/` — same path as the real cluster, but authenticated with the bnna bearer token rather than a PVE token: ```sh # e.g. get LXC config curl -sSL -H "$AUTH" "$BNNA_HOST/api2/json/nodes/pve1/lxc/1104232/config" # e.g. get task status curl -sSL -H "$AUTH" "$BNNA_HOST/api2/json/nodes/pve1/tasks/UPID:.../status" ``` --- ## LOCAL.md Values Needed | Key | Where | |-----|-------| | Service URL | `LOCAL.md` → Dev Service CT → Service URL | | Admin token | `LOCAL.md` → Dev Service CT → Admin token | | Account bunch slugs | `LOCAL.md` → Active Dev Containers, or `GET /api/accounts` | | DNS provider ID | `LOCAL.md` → DNS Providers, or `GET /api/accounts/{slug}/dns/providers` | --- ## SSH to a CNAME Domain (TLS Router Limitation) The TLS router resolves hostnames one layer deep. SSH to the direct-IP hostname (`tls-{ip-dashes}.{DIRECT_IP_DOMAIN}`) works directly. SSH to a CNAME that points to a `tls-*` hostname (e.g. `myapp.example.com → tls-10-11-4-42.a.bnna.net`) does NOT work directly — the router doesn't chase the CNAME. **Option A — SSH config `Hostname` override (recommended):** ``` Host myapp.example.com Hostname tls-10-11-4-42.a.bnna.net User root ``` **Option B — SSH command with explicit hostname:** ```sh ssh -o "Hostname tls-10-11-4-42.a.bnna.net" root@myapp.example.com ``` **Option C — `sclient` ProxyCommand (if routing through TLS ALPN):** ``` Host tls-*.a.bnna.net ProxyCommand sclient %h 22 ``` The fix (automatic CNAME chasing in the TLS router) is on the TODO list. See `LOCAL.md` for real hostnames for this deployment. --- ## Sync / Repair ACLs for an Account Idempotently reapply the correct least-privilege Proxmox ACLs for an account's group. Safe to run on existing accounts after ACL drift or when migrating from `PVEAdmin` to least-privilege. ```sh # Minimal — applies pool ACLs and zone ACL (if network is assigned in DB) curl -sSL -X PUT -H "$AUTH" \ -H "Content-Type: application/json" \ "$BNNA_HOST/api/accounts/{slug}/acls" \ --data '{}' # With storage IDs curl -sSL -X PUT -H "$AUTH" \ -H "Content-Type: application/json" \ "$BNNA_HOST/api/accounts/{slug}/acls" \ --data '{ "storageMain": "slc1-tank1-{slug}", "storageDir": "slc1-tank1-{slug}-dir", "storagePbs": "pbs1-{slug}1" }' # Returns: {"ACLsSet": 7} ``` Pool ACLs and zone ACL are always derived from the account DB record. Storage IDs must be provided explicitly — they are not stored in the DB.