Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Barbacane image version to pull from ghcr.io/barbacane-dev
# Set to a specific release tag (e.g. 0.9.0) to pin the playground to that version.
# Set to a specific release tag (e.g. 0.10.0) to pin the playground to that version.
# Use "latest" to always pull the most recent release.
BARBACANE_VERSION=0.9.0
BARBACANE_VERSION=0.10.0

# Admin token for the control plane. Since 0.8 the control plane refuses to
# start without a non-empty token. This is a demo default — change it for any
Expand Down
78 changes: 78 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: CI

on:
pull_request:
push:
branches: [main]
schedule:
# Nightly, against the `latest` gateway image, to catch upstream drift.
- cron: '0 5 * * *'
workflow_dispatch:
inputs:
version:
description: Barbacane image tag to test (defaults to the pin in .env.example)
required: false
default: ''

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
# Resolve which gateway image tag this run tests: the nightly schedule uses
# `latest`, a manual run uses its input, everything else uses the .env.example
# pin.
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.pick.outputs.tag }}
steps:
- uses: actions/checkout@v5
- id: pick
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
TAG=latest
elif [ -n "${{ github.event.inputs.version }}" ]; then
TAG="${{ github.event.inputs.version }}"
else
TAG="$(grep '^BARBACANE_VERSION=' .env.example | cut -d= -f2)"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Testing Barbacane image tag: $TAG"

# Fast gate: the specs must compile with this gateway build.
compile:
needs: version
runs-on: ubuntu-latest
env:
BARBACANE_VERSION: ${{ needs.version.outputs.tag }}
steps:
- uses: actions/checkout@v5
- name: Compile the specs
run: docker compose run --rm compiler

# Full gate: bring up the stack and exercise every demonstrated use case.
smoke:
needs: version
runs-on: ubuntu-latest
env:
BARBACANE_VERSION: ${{ needs.version.outputs.tag }}
steps:
- uses: actions/checkout@v5

- name: Start the stack
# The observability viewers (Grafana/Loki/Tempo/Alloy) are not needed for
# the smoke test; bring up only the gateway, its upstreams, the asset
# seeder and Prometheus. Compose pulls in each service's dependencies.
run: docker compose up -d --pull always barbacane prometheus rustfs-init

- name: Run the smoke test
run: bash scripts/smoke.sh

- name: Dump logs on failure
if: failure()
run: docker compose logs --no-color --tail 300

- name: Tear down
if: always()
run: docker compose down -v
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ By default the playground pulls the `latest` images from [GitHub Container Regis

```bash
cp .env.example .env
# Edit .env and set BARBACANE_VERSION=0.8.1 (or any release tag)
# Edit .env and set BARBACANE_VERSION=0.10.0 (or any release tag)
docker compose pull
docker compose up -d
```
Expand Down Expand Up @@ -135,7 +135,7 @@ The `/stations` endpoint has a 500ms SLO. Violations are logged and emit metrics

```bash
# Check metrics for SLO violations
curl http://localhost:8080/__barbacane/metrics | grep slo
curl http://localhost:8082/metrics | grep slo
```

### Request Validation
Expand Down Expand Up @@ -185,13 +185,14 @@ curl -X OPTIONS http://localhost:8080/stations \
-v 2>&1 | grep -i "access-control"
```

### WAF (SQLi / XSS)
### WAF (SQLi / XSS + outbound leak)

The gateway runs a ModSecurity / OWASP CRS compatible WAF, configured on the
spec with `x-barbacane-waf` (see `specs/waf-demo.yaml`). Rules compile at build
time and seal into the artifact. The demo scores SQL injection, cross-site
scripting and path traversal, and blocks once the inbound anomaly score reaches
the threshold.
scripting and path traversal on the request and blocks once the inbound anomaly
score reaches the threshold, and inspects the response body for a leaked
credential (a phase-4 rule).

```bash
# Benign query passes
Expand All @@ -209,13 +210,18 @@ curl "http://localhost:8080/waf/search?q=%3Cscript%3Ealert(1)%3C/script%3E"
# Path traversal is blocked
curl "http://localhost:8080/waf/search?q=../../etc/passwd"
# 403 Forbidden

# A response body carrying an AWS key is blocked on the way out (phase 4)
curl "http://localhost:8080/waf/leak"
# 403 Forbidden
```

The rule set pairs regex signatures with the libinjection classifiers
(`@detectSQLi`, `@detectXSS`), which add coverage for obfuscated injection that
signatures miss. With `unsupported_rules: skip` the classifier rules build on
any gateway image and activate on one that implements them. See
`playground.http` for the full request set.
signatures miss. Every inspected transaction is written to the `waf.audit` log
target (`audit: on`); see it with
`podman logs playground_barbacane_1 | grep waf.audit`. See `playground.http`
for the full request set.

## Observability

Expand All @@ -235,7 +241,7 @@ The pre-configured **Barbacane API Gateway** dashboard shows:
View raw Prometheus metrics:

```bash
curl http://localhost:8080/__barbacane/metrics
curl http://localhost:8082/metrics
```

Key metrics:
Expand Down
6 changes: 3 additions & 3 deletions configs/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ global:
evaluation_interval: 15s

scrape_configs:
# Scrape Barbacane gateway metrics
# Scrape Barbacane gateway metrics from the admin API port.
- job_name: 'barbacane'
static_configs:
- targets: ['barbacane:8080']
metrics_path: '/__barbacane/metrics'
- targets: ['barbacane:8082']
metrics_path: '/metrics'
scrape_interval: 5s

# Scrape Prometheus itself
Expand Down
19 changes: 15 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ services:
# Spec Compiler (init container - compiles specs before gateway starts)
# ==========================================================================
compiler:
image: ghcr.io/barbacane-dev/barbacane-standalone:${BARBACANE_VERSION:-0.9.0}
image: ghcr.io/barbacane-dev/barbacane-standalone:${BARBACANE_VERSION:-0.10.0}
command: ["compile", "--spec", "/specs/train-travel-api.yaml", "--spec", "/specs/train-events.yaml", "--spec", "/specs/s3-proxy.yaml", "--spec", "/specs/waf-demo.yaml", "--manifest", "/manifest/barbacane.yaml", "--output", "/output/api.bca", "--allow-plaintext"]
# Run as root so the init container can write the shared artifact volume,
# which is root-owned by default. The gateway mounts it read-only.
user: "0:0"
volumes:
- ./specs:/specs:ro
- ./barbacane.yaml:/manifest/barbacane.yaml:ro
Expand All @@ -33,11 +36,19 @@ services:
# API Gateway (Data Plane)
# ==========================================================================
barbacane:
image: ghcr.io/barbacane-dev/barbacane-standalone:${BARBACANE_VERSION:-0.9.0}
command: ["serve", "--artifact", "/config/api.bca", "--allow-plaintext-upstream"]
image: ghcr.io/barbacane-dev/barbacane-standalone:${BARBACANE_VERSION:-0.10.0}
# --admin-bind on 0.0.0.0 exposes the admin API (/health, /metrics,
# /provenance) to Prometheus and the host. It defaults to loopback, which
# other containers cannot reach. The admin endpoints are unauthenticated;
# keep this on an internal network in production.
command: ["serve", "--artifact", "/config/api.bca", "--allow-plaintext-upstream", "--admin-bind", "0.0.0.0:8082"]
ports:
- "8080:8080"
- "8443:8443"
# Admin API (metrics, health, provenance). Unauthenticated, so publish it
# to loopback only; Prometheus reaches the container port over the Docker
# network regardless.
- "127.0.0.1:8082:8082"
volumes:
- artifact_data:/config:ro
environment:
Expand Down Expand Up @@ -77,7 +88,7 @@ services:
# Control Plane (Web UI + API)
# ==========================================================================
control-plane:
image: ghcr.io/barbacane-dev/barbacane-control:${BARBACANE_VERSION:-0.9.0}
image: ghcr.io/barbacane-dev/barbacane-control:${BARBACANE_VERSION:-0.10.0}
ports:
- "3001:80" # Web UI
- "9091:9090" # Control Plane API
Expand Down
12 changes: 8 additions & 4 deletions playground.http
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

@baseUrl = http://localhost:8080
@oauthUrl = http://localhost:9099
@metricsUrl = http://localhost:8080/__barbacane/metrics
@metricsUrl = http://localhost:8082/metrics
@wiremockUrl = http://localhost:8081

# ===========================================================================
Expand Down Expand Up @@ -218,13 +218,13 @@ GET {{wiremockUrl}}/__admin/mappings
GET {{wiremockUrl}}/__admin/requests

# ===========================================================================
# WAF (SQLi / XSS / traversal): see specs/waf-demo.yaml
# WAF (SQLi / XSS / traversal + outbound leak): see specs/waf-demo.yaml
# ===========================================================================
# Blocking mode. Benign requests pass (200); attacks reach the inbound
# anomaly threshold and are refused (403). The rule set scores each attack
# with both regex signatures and the libinjection classifiers
# (@detectSQLi/@detectXSS); the classifiers add coverage for obfuscated
# payloads and activate once the gateway image ships them.
# (@detectSQLi/@detectXSS), which add coverage for obfuscated payloads. Every
# inspected transaction is written to the `waf.audit` log target (audit: on).

### Benign search: passes (200)
GET {{baseUrl}}/waf/search?q=paris
Expand Down Expand Up @@ -252,3 +252,7 @@ POST {{baseUrl}}/waf/submit
Content-Type: application/json

{"comment": "'; DROP TABLE users; --"}

### Outbound leak: the upstream response body carries an AWS key, so a
### response-phase (phase 4) rule blocks the response with 403.
GET {{baseUrl}}/waf/leak
122 changes: 122 additions & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Smoke test for the Barbacane playground. Exercises every demonstrated use case
# against a running stack and exits non-zero if any check fails.
#
# Assumes the stack is already up (docker compose up -d). Waits for the gateway
# to become ready, then runs the checks. Override the endpoints with env vars:
# BASE_URL (default http://localhost:8080) data plane
# ADMIN_URL (default http://localhost:8082) admin API (metrics/health)
# OAUTH_URL (default http://localhost:9099) mock OIDC provider
# PROM_URL (default http://localhost:9090) Prometheus
set -uo pipefail

BASE_URL="${BASE_URL:-http://localhost:8080}"
ADMIN_URL="${ADMIN_URL:-http://localhost:8082}"
OAUTH_URL="${OAUTH_URL:-http://localhost:9099}"
PROM_URL="${PROM_URL:-http://localhost:9090}"

pass=0
fail=0

code() { curl -s -o /dev/null -w "%{http_code}" "$@"; }

# check "<space-separated acceptable codes>" "<description>" <curl args...>
check() {
local want="$1"; shift
local desc="$1"; shift
local got; got="$(code "$@")"
if echo " $want " | grep -q " $got "; then
echo "PASS [$got] $desc"; pass=$((pass + 1))
else
echo "FAIL [got $got, want $want] $desc"; fail=$((fail + 1))
fi
}

echo "== waiting for the gateway =="
i=0
until [ "$(code "$BASE_URL/__barbacane/health")" = "200" ]; do
i=$((i + 1))
if [ "$i" -gt 60 ]; then echo "gateway did not become ready in time"; exit 1; fi
sleep 2
done
echo "gateway ready after $((i * 2))s"

# The public CDN asset is seeded by the rustfs-init container after the gateway
# is already serving, so wait for it before the checks race the seeder.
echo "== waiting for the seeded asset =="
i=0
until [ "$(code "$BASE_URL/assets/welcome.txt")" = "200" ]; do
i=$((i + 1))
if [ "$i" -gt 30 ]; then echo "warning: seeded asset not ready after 60s; the CDN check will report it"; break; fi
sleep 2
done

echo "== core =="
check "200" "GET /stations" "$BASE_URL/stations"
check "400" "GET /stations?country=invalid (schema validation)" "$BASE_URL/stations?country=invalid"
check "401" "GET /bookings without a token" "$BASE_URL/bookings"
check "200" "GET /assets/welcome.txt (public CDN)" "$BASE_URL/assets/welcome.txt"

echo "== OIDC =="
TOKEN="$(curl -s -X POST "$OAUTH_URL/barbacane/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials&scope=openid&client_id=playground&client_secret=secret' \
| python3 -c 'import sys, json; print(json.load(sys.stdin).get("access_token", ""))' 2>/dev/null)"
if [ -n "$TOKEN" ]; then echo "PASS got an access token"; pass=$((pass + 1)); else echo "FAIL no access token"; fail=$((fail + 1)); fi
AUTH="Authorization: Bearer $TOKEN"

echo "== bookings / events / s3 / mcp =="
check "200" "GET /bookings with a token" -H "$AUTH" "$BASE_URL/bookings"
check "202 200" "POST /events/trips/delayed (NATS dispatch)" \
-X POST -H 'Content-Type: application/json' \
-d '{"event_type":"trip.delayed","trip_id":"f08d2c3e-8d6f-7f5f-2c1f-4e5f6a7b8c9d","delay_minutes":15,"reason":"weather","timestamp":"2025-03-15T10:30:00Z"}' \
"$BASE_URL/events/trips/delayed"
check "200 201 204" "PUT /storage/playground/hello.txt" -X PUT -H "$AUTH" -H 'Content-Type: text/plain' -d 'Hello' "$BASE_URL/storage/playground/hello.txt"
check "200" "GET /storage/playground/hello.txt" -H "$AUTH" "$BASE_URL/storage/playground/hello.txt"
check "200 204" "DELETE /storage/playground/hello.txt" -X DELETE -H "$AUTH" "$BASE_URL/storage/playground/hello.txt"
check "200" "POST /__barbacane/mcp initialize" -X POST -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"smoke","version":"1"}}}' \
"$BASE_URL/__barbacane/mcp"

echo "== CORS =="
check "200 204" "OPTIONS /stations (CORS preflight)" \
-X OPTIONS -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: GET' "$BASE_URL/stations"

echo "== WAF request-phase =="
check "200" "benign query" "$BASE_URL/waf/search?q=paris"
check "403" "SQLi tautology" "$BASE_URL/waf/search?q=1'%20OR%20'1'='1"
check "403" "SQLi union select" "$BASE_URL/waf/search?q=1%20UNION%20SELECT%20x"
check "403" "XSS" "$BASE_URL/waf/search?q=%3Cscript%3Ealert(1)%3C/script%3E"
check "403" "path traversal" "$BASE_URL/waf/search?q=../../etc/passwd"
check "200" "benign JSON body" -X POST -H 'Content-Type: application/json' -d '{"comment":"hi"}' "$BASE_URL/waf/submit"
check "403" "SQLi JSON body" -X POST -H 'Content-Type: application/json' -d '{"comment":"1 UNION SELECT x"}' "$BASE_URL/waf/submit"

echo "== WAF response-phase =="
check "403" "outbound leak /waf/leak (phase 4)" "$BASE_URL/waf/leak"

echo "== admin API =="
check "200" "GET /health" "$ADMIN_URL/health"
check "200" "GET /metrics" "$ADMIN_URL/metrics"

echo "== Prometheus scrape =="
# Give Prometheus a scrape interval to reach the gateway.
target_up=""
for _ in $(seq 1 12); do
if curl -s "$PROM_URL/api/v1/targets?state=active" 2>/dev/null | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(1)
ts = d.get('data', {}).get('activeTargets', [])
sys.exit(0 if any(t['labels'].get('job') == 'barbacane' and t.get('health') == 'up' for t in ts) else 1)
"; then
target_up=1; break
fi
sleep 5
done
if [ -n "$target_up" ]; then echo "PASS Prometheus barbacane target is up"; pass=$((pass + 1)); else echo "FAIL Prometheus barbacane target is not up"; fail=$((fail + 1)); fi

echo
echo "RESULT: pass=$pass fail=$fail"
[ "$fail" -eq 0 ]
Loading