Backend Integration

How to drive patchpanel from CI/CD pipelines, infrastructure-as-code, and monitoring stacks. The REST API is the same surface the in-app UI uses — anything you can click, you can curl.

Table of contents

  1. TOC

The mental model

Patchpanel has one canonical data shape: state.json. The renderer produces haproxy.cfg from it deterministically. Every change goes through the apply pipeline:

PUT /api/state
   ↓
Zod validate                  → 422 with `issues[]` on fail
   ↓
Render haproxy.cfg
   ↓
haproxy -c                    → 502 with `output` + `hints[]` on fail
   ↓
Atomic swap on disk
   ↓
Reload via master CLI socket  → rollback to .bak on fail
   ↓
Snapshot + audit entry
   ↓
200 OK with the persisted state

For automation, this means one transactional pattern: read the state, modify it locally, push it back. Patchpanel does the rest. If anything goes wrong, the daemon rolls back; you get a structured error with enough detail to surface in your CI logs.

For ops that don’t need a full apply (drain a server, set a weight, clear a counter), there’s a separate set of runtime endpoints that hit HAProxy’s admin socket directly — no reload, no snapshot, in-memory only.

Authentication

Mint a long-lived API token once, store the wire format in your secret manager:

# As admin via cookie session first
TOKEN_RESP=$(curl -ksb cookies.txt \
  -X POST -H 'content-type: application/json' \
  -d '{"name":"ci-pipeline"}' \
  https://patchpanel.example.com:8099/api/api-tokens)

PP_TOKEN=$(echo "$TOKEN_RESP" | jq -r .wire)
# pp_a1b2c3d4.0123456789abcdef0123456789abcdef — store immediately

Wire format: pp_<8-hex>.<32-hex>. Send as Bearer on every call:

curl -H "Authorization: Bearer $PP_TOKEN" \
     https://patchpanel.example.com:8099/api/state

Tokens are bcrypt-hashed at rest — no recovery path. Lose the wire, mint a new one + revoke the old via DELETE /api/api-tokens/{keyId}. See Authentication for the full token model.

The state document pattern (the spine)

# 1. Pull the canonical state
curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/state > state.json

# 2. Patch it locally — jq, yq, jsonnet, or whatever
jq '.acls[0].values += ["newhost.example.com"]' state.json > state.next.json

# 3. Push it back — pipeline runs render → validate → swap → reload → snapshot
curl -fs -X PUT -H "Authorization: Bearer $PP_TOKEN" \
     -H 'content-type: application/json' \
     -d @state.next.json \
     https://patchpanel.example.com:8099/api/state

Status codes you’ll see on PUT /api/state:

CodeWhat it meansBody shape
200Applied; HAProxy reloaded; snapshot + audit writtenThe persisted state
422Zod schema validation failed{error, issues: [...]} with Zod’s issue list
502haproxy -c rejected the rendered cfg, OR the master-socket reload failed; rolled back{error, output, hints: [...]}output is HAProxy’s stderr, hints is patchpanel’s parsed structured guesses
409State not initialized (very rare — only on a corrupted-from-day-zero install){error}

Idempotent: the state document is the full desired state. PUTting the same document twice is a no-op (well, it still rerenders + reloads, but the resulting cfg is identical).

Certificate automation

Let’s Encrypt: trigger a renewal

# All certs
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"force":false}' \
  https://patchpanel.example.com:8099/api/certificates/renew

# One cert by id
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"force":false}' \
  https://patchpanel.example.com:8099/api/certificates/<certId>/renew

Pass "force": true for --force-renewal (ignores certbot’s not-yet-due check). The 200 response includes a results[] array with per-cert outcome and a reload: {ok, error} block for the post-renewal HAProxy reload.

BYO certs: upload PEMs

Two-step: upload the bytes, then add the state entry.

# Step 1: upload PEMs
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d @- https://patchpanel.example.com:8099/api/byo-certs/upload <<EOF
{
  "name": "internal-app",
  "fullchainPem": "$(awk '{printf "%s\\n", $0}' fullchain.pem)",
  "privkeyPem": "$(awk '{printf "%s\\n", $0}' privkey.pem)"
}
EOF

# Step 2: add the cert to state.tls.certs + state.tls.providers
# (read state.json, jq it, PUT back)

The byo-certs/upload endpoint validates the PEM pair (privkey matches leaf, returns SANs + notAfter) and writes <byoCertsDir>/<name>/{fullchain,privkey,cert}.pem mode 0600. The state entry must be added separately via PUT /api/state.

Dry-run validation (no disk write):

curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d @- https://patchpanel.example.com:8099/api/byo-certs/validate <<EOF
{
  "fullchainPem": "...",
  "privkeyPem": "..."
}
EOF

Trusted CAs and CRLs

Same pattern — upload to /api/trusted-cas/upload (or trusted-crls/upload), then add the state entry to state.trustedCas[] / state.trustedCrls[] via PUT /state.

Runtime HAProxy ops (no reload)

These hit HAProxy’s admin socket directly. Mutations are in-memory only — they survive a reload but not a process restart. Use them for fast operational moves that don’t need to round-trip through render + validate + swap + reload.

Drain a server

curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"state":"drain"}' \
  https://patchpanel.example.com:8099/api/haproxy/servers/web-pool/web-1/state

state is one of ready, drain, maint. Drain stops new sessions but keeps existing ones. Maint disables fully. Ready re-enables.

Set weight 0–256

curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"weight":0}' \
  https://patchpanel.example.com:8099/api/haproxy/servers/web-pool/web-1/weight

Disable a frontend

curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/runtime/frontends/fe-https/disable

# Re-enable
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/runtime/frontends/fe-https/enable

maxconn

# Per-frontend
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"max":5000}' \
  https://patchpanel.example.com:8099/api/runtime/maxconn/frontend/fe-https

# Global
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"max":50000}' \
  https://patchpanel.example.com:8099/api/runtime/maxconn/global

Counters / sessions / tables

# Reset every max/total counter (useful before a benchmark)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/runtime/counters/clear

# Kill one session by id (from `show sess`)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/runtime/sessions/0x7fabc/shutdown

# Clear a stick table (whole table or one key)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"key":"1.2.3.4"}' \
  https://patchpanel.example.com:8099/api/runtime/tables/rate_per_ip/clear

# Add a runtime ACL entry (does NOT persist if ACL isn't file-backed)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"value":"1.2.3.4"}' \
  https://patchpanel.example.com:8099/api/runtime/acls/0/entries

Observability scraping

Snapshot stats

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/stats
# {"info": {...show info}, "stat": [...show stat rows]}

Time-series (last hour, rolling)

LAST=$(date +%s000 --date '1 minute ago')   # epoch ms
curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  "https://patchpanel.example.com:8099/api/stats/history?since=$LAST"

since is epoch ms. Without it, you get the full hour. Useful for Prometheus / InfluxDB delta-pulls.

Top-N slowest backends

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  "https://patchpanel.example.com:8099/api/stats/slowest-backends?limit=20"

HTTP status code distribution

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/stats/http-codes
# {"totals": {"1xx": n, "2xx": n, "3xx": n, "4xx": n, "5xx": n, "other": n}}

Sessions + geo

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/stats/sessions

When state.geoip.enabled === true, the top 20 clients are geo-enriched (country, city, ASN). Useful for the dashboard origin map; equally useful for piping into a security dashboard.

Audit log

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  "https://patchpanel.example.com:8099/api/audit?limit=200&category=auth"

Filter by category (e.g. state, cert, haproxy, auth, api-token, cluster, client-error, snapshot, lua-plugin, tls-credentials, trusted-ca, trusted-crl) and actor. Default limit 100, max 1000.

Ship this somewhere durable — patchpanel auto-vacuums after logging.auditRetentionDays (default 365).

Snapshots + rollback

Every successful PUT /api/state writes a snapshot. List:

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/snapshots
# {"snapshots": [{"id": "2026-05-17T12-34-56Z-abc1234", "snapshotAt": "...", "actor": "...", "reason": null}, ...]}

Read one:

curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/snapshots/2026-05-17T12-34-56Z-abc1234
# Full {id, snapshotAt, actor, reason, state}

Restore (runs the full apply pipeline; audit reason: restore:<id>):

curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/snapshots/2026-05-17T12-34-56Z-abc1234/restore

Snapshot IDs are ISO-timestamp + 7-char hash. Sortable by ID.

Webhooks / notifications

Notification channels live in state.notifications.channels[]. CRUD them through PUT /api/state. Then:

# Channel-type schemas (drive the channel-create UI)
curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/notifications/channel-types

# Send a test notification to one configured channel
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"channelId":"ch-abc"}' \
  https://patchpanel.example.com:8099/api/notifications/test

# Manual dispatch (diagnostic; real events come from server-side hooks)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"category":"cert","level":"warning","message":"test"}' \
  https://patchpanel.example.com:8099/api/notifications/dispatch

Cluster sync (multi-node patchpanel)

Patchpanel supports an operator-paste pairing model for multi-node deployments. There’s no handshake — you mint an inbound token on one node and paste it into the other’s “Add peer” form. Run the flow twice (once on each node) for bidirectional sync.

Two distinct API surfaces:

# List configured peers (outbound tokens redacted)
curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/peers

# Mint an inbound token (the raw value is returned ONCE)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"label":"node-a-pairing"}' \
  https://patchpanel.example.com:8099/api/peers/inbound-tokens

# Add a peer (using a token THEY minted, pasted here)
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"url":"https://node-b.example.com:8099","name":"node-b","token":"<raw inbound token from node-b>"}' \
  https://patchpanel.example.com:8099/api/peers

# Drift report — checksum diff vs each peer
curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/peers/drift

# Push current state to all peers immediately
curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
  https://patchpanel.example.com:8099/api/peers/peer-a1b2c3d4e5f6/sync-now

Server-to-server surface (/api/peer/*) — RAW inbound token

These endpoints take the RAW inbound token (not a pp_<keyId>.<secret> API token) as the Bearer value. Peer nodes call them; you typically don’t.

# Clock skew probe
curl -fs -H "Authorization: Bearer <raw-inbound-token>" \
  https://patchpanel.example.com:8099/api/peer/clock

# State checksum (compare against drift report)
curl -fs -H "Authorization: Bearer <raw-inbound-token>" \
  https://patchpanel.example.com:8099/api/peer/state-checksum

# Push state (full apply pipeline)
curl -fs -X POST -H "Authorization: Bearer <raw-inbound-token>" \
  -H 'content-type: application/json' \
  -d @state.json \
  https://patchpanel.example.com:8099/api/peer/state

Important: /api/peers/* (plural — list/CRUD/management) takes a normal pp_<keyId>.<secret> token. /api/peer/* (singular — server-to-server) takes a raw inbound token. Don’t mix them.

CI/CD recipes

GitHub Actions — nightly cert renewal

name: patchpanel cert renew
on:
  schedule:
    - cron: '0 3 * * *'
  workflow_dispatch:

jobs:
  renew:
    runs-on: ubuntu-latest
    steps:
      - name: Force-renew patchpanel certs
        env:
          PP_HOST: https://patchpanel.example.com:8099
          PP_TOKEN: $
        run: |
          curl -fsSL \
            -X POST \
            -H "Authorization: Bearer $PP_TOKEN" \
            -H 'content-type: application/json' \
            -d '{"force":false}' \
            $PP_HOST/api/certificates/renew \
            | jq .

Terraform-style state management

Treat patchpanel’s state.json the way Terraform treats its plan/apply. Use the snapshot endpoint as your “last known good”:

#!/usr/bin/env bash
set -euo pipefail
: "${PP_HOST:?}" "${PP_TOKEN:?}"

# 1. Snapshot-before
ROLLBACK_ID=$(curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  $PP_HOST/api/snapshots | jq -r '.snapshots[0].id')

# 2. Pull
curl -fs -H "Authorization: Bearer $PP_TOKEN" \
  $PP_HOST/api/state > state.json

# 3. Patch (replace with your editor of choice)
jq '
  .haproxy.backends[] |= (
    if .id == "web-pool" then
      .servers += [{name:"web-3", address:"10.0.0.13:443", weight:100}]
    else . end
  )
' state.json > state.next.json

# 4. Push
HTTP_CODE=$(curl -s -o response.json -w '%{http_code}' \
  -X PUT -H "Authorization: Bearer $PP_TOKEN" \
  -H 'content-type: application/json' \
  -d @state.next.json \
  $PP_HOST/api/state)

if [ "$HTTP_CODE" != "200" ]; then
  echo "Apply failed (HTTP $HTTP_CODE):" >&2
  cat response.json >&2
  echo "" >&2
  echo "Rolling back to snapshot $ROLLBACK_ID..." >&2
  curl -fs -X POST -H "Authorization: Bearer $PP_TOKEN" \
    "$PP_HOST/api/snapshots/$ROLLBACK_ID/restore"
  exit 1
fi

Auto-rollback on reload failure already happens server-side (the daemon’s .bak restore). The script-level rollback above is for the case where the apply succeeds but the change is operationally wrong (e.g. you added a bad backend).

Ansible — graceful drain before deploy

VAR=lookup('env', 'PATCHPANEL_TOKEN')
- name: Drain backend server before deploy
  hosts: localhost
  vars:
    pp_host: https://patchpanel.example.com:8099
    pp_token: ''
    backend: web-pool
    server: web-1
  tasks:
    - name: Drain
      uri:
        url: '/api/haproxy/servers///state'
        method: POST
        headers:
          Authorization: 'Bearer '
        body_format: json
        body: { state: 'drain' }
        status_code: 200

    - name: Wait for in-flight requests to clear
      pause:
        seconds: 30

    - name: Set weight 0 (belt-and-suspenders)
      uri:
        url: '/api/haproxy/servers///weight'
        method: POST
        headers:
          Authorization: 'Bearer '
        body_format: json
        body: { weight: 0 }

    - name: Deploy your app here
      # ...

    - name: Re-enable after deploy
      uri:
        url: '/api/haproxy/servers///state'
        method: POST
        headers:
          Authorization: 'Bearer '
        body_format: json
        body: { state: 'ready' }

Prometheus scrape

Patchpanel doesn’t expose a Prometheus endpoint directly — but GET /api/stats returns parseable JSON. A small exporter:

# patchpanel_exporter.py — sketch
import os, requests
from prometheus_client import start_http_server, Gauge

PP_HOST = os.environ["PP_HOST"]
PP_TOKEN = os.environ["PP_TOKEN"]
HEADERS = {"Authorization": f"Bearer {PP_TOKEN}"}

backend_rtime = Gauge("haproxy_backend_rtime_ms", "Average response time", ["backend"])
backend_qcur = Gauge("haproxy_backend_qcur", "Queued requests", ["backend"])

def collect():
    r = requests.get(f"{PP_HOST}/api/stats", headers=HEADERS, verify=False)
    r.raise_for_status()
    for row in r.json()["stat"]:
        if row.get("type") == "backend":
            backend_rtime.labels(backend=row["pxname"]).set(row.get("rtime", 0))
            backend_qcur.labels(backend=row["pxname"]).set(row.get("qcur", 0))

# Schedule collect() every 15s, expose on :9913

For more comprehensive Prometheus coverage, point Prometheus directly at HAProxy’s stats endpoint (patchpanel renders a stats listener for you if you configure one in state).

Error envelope reference

CodeEnvelopeWhen
400{error} or {ok: false, error}Bad input — missing field, invalid id pattern, wrong enum value, malformed body
401{error: "AuthError", message: "authentication required"}Missing/invalid auth. SSE endpoints get streaming 401; HTML clients get 302 to /login?return=; everyone else gets JSON.
403{error: "ForbiddenError", message}Authenticated but lacking the required role / session type
404{error} or {ok: false, error}Resource not found
409{error}State not initialized; resource in conflicting state
422{error, issues: [...]}Zod schema validation failed on PUT /api/state or POST /api/peer/state. issues[] is verbatim Zod issue objects.
500{error, message}Internal — see audit log + journalctl
502{error, output, hints: [...]}haproxy -c rejected the rendered cfg, OR master-socket reload failed. output = HAProxy stderr verbatim; hints[] = patchpanel’s parsed structured guesses with severity, line, message, and resolved entity (e.g. {kind: 'acl', name: 'host_typo'}) where possible. Rolled back automatically.
503{error}Background service (e.g. stats sampler) not running. Retry.

The apiGet/apiPost/apiPut/apiDelete/apiPatch helpers in web/src/api/client.js throw Error with .status and .payload on non-2xx — the React UI unwraps .payload.message for the toast.

Rate limits

Three tiered buckets (configurable in config.yaml):

TierDefaultApplies to
rateLimit.authMax / rateLimit.authWindowMs25 req / 15 min/api/auth/login, /api/auth/change-password, /api/setup/complete
rateLimit.writeMax / rateLimit.writeWindowMs60 req / 1 minEvery mutating endpoint (POST, PUT, PATCH, DELETE)
rateLimit.readMax / rateLimit.readWindowMs1000 req / 1 minEvery read endpoint

Response headers per request (draft-ietf-httpapi-ratelimit-headers):

RateLimit-Limit: 60
RateLimit-Remaining: 47
RateLimit-Reset: 23

When you hit the limit you get 429 Too Many Requests with the Retry-After header. CI tooling should respect the headers — back off rather than retry-storm.

Backup hooks

Patchpanel ships backup-pre and backup-post CLI hooks for the host backup tool (restic, borg, rsync, snapshots):

sudo -u patchpanel patchpanel backup-pre
# rsync / restic / borg the /var/lib/patchpanel tree
sudo -u patchpanel patchpanel backup-post

backup-pre quiesces in-flight writes; backup-post resumes. This matters most for audit.sqlite (WAL-mode SQLite races with raw cp).

See also