Config schema¶
Barbacana is configured with a single YAML file. You never write Caddy config — the YAML is compiled internally.
Top-level structure¶
version: v1alpha1 # schema version, required
host: "api.example.com" # Mode 1: single host, auto-TLS
port: 8080 # Mode 3: behind LB (mutually exclusive with host)
data_dir: "/data/barbacana" # optional, default "/data/barbacana"
metrics_port: 9090 # optional, default 0 (disabled)
health_port: 8081 # optional, default 0 (disabled)
audit_log: # optional; controls the wire schema of stdout audit entries
format: ocsf # "ocsf" (default) or "ecs"
tracing: # optional; off entirely when block absent or enabled: false
enabled: false
global:
# defaults applied to every route unless the route overrides
routes:
- # one block per route
Required vs optional¶
| Field | Required | Default | Validation |
|---|---|---|---|
version |
yes | — | must equal v1alpha1 |
host |
no | — | valid hostname; mutually exclusive with port and with any route-level match.hosts |
port |
no | 8080 (only when no host and no route has match.hosts) |
integer 1–65535; mutually exclusive with host and with any route-level match.hosts |
data_dir |
no | /data/barbacana |
directory must be writable; stores TLS certificates and ACME state — mount as a persistent volume in containers |
metrics_port |
no | 0 (disabled) |
integer 0–65535; 0 disables the listener; when non-zero, must differ from port and health_port |
health_port |
no | 0 (disabled) |
integer 0–65535; 0 disables the listener; when non-zero, must differ from port and metrics_port |
audit_log |
no | format: ocsf |
see "Audit log" section below |
tracing |
no | disabled | see "Tracing" section below |
global |
no | see below | — |
routes |
yes | — | at least one route |
Opt-in observability ports¶
metrics_port and health_port default to 0, which means the corresponding listener is never started: no port is opened, no endpoint is served. Audit logs go to stdout regardless and are always on.
This opt-in is deliberate. An open port is attack surface — /metrics exposes route IDs and protection names; /healthz advertises that a WAF is running. A hobbyist who forwards :443 on their home router should not unknowingly expose two additional operational-data ports. Production deployments set both ports explicitly.
When a port is 0, the server emits an info log at startup so operators know why the endpoint is missing:
health endpoint disabled — set health_port to enable /healthz and /readyz
metrics endpoint disabled — set metrics_port to enable /metrics
Deployment modes¶
Exactly one of three mutually exclusive modes is selected by the combination of host, port, and route-level match.hosts. See Hostnames & HTTPS for the full picture.
Mode 1 — Single host, auto-TLS. Set top-level host. Barbacana serves HTTPS on :443, redirects HTTP on :80, and provisions a Let's Encrypt certificate automatically. Routes must not set match.hosts, and port must not be set.
Mode 2 — Multi-host, auto-TLS. Omit host. Every route supplies match.hosts. Barbacana provisions one certificate per hostname. If any route has match.hosts, every route must have match.hosts. port must not be set.
version: v1alpha1
routes:
- match:
hosts: [api.example.com]
upstream: http://api:8000
- match:
hosts: [admin.example.com]
upstream: http://admin:8000
Mode 3 — Behind a load balancer, plain HTTP. Set port (or leave both host and port unset to default port to 8080). Barbacana serves plain HTTP on the configured port; there is no TLS and no certificate provisioning.
Validation errors (modes)¶
Every mode constraint is a hard error, not a warning. Messages name the specific conflicting fields and, where applicable, the offending route:
waf.yaml:2: "host" and "port" are mutually exclusive — use "host" for auto-TLS or "port" for plain HTTP behind a load balancer
waf.yaml:3: "host" and "match.hosts" on route "api" are mutually exclusive — use top-level "host" for a single hostname or "match.hosts" per route for multiple hostnames
waf.yaml:5: "port" and "match.hosts" on route "api" are mutually exclusive — "match.hosts" requires auto-TLS; remove "port" or remove "match.hosts"
waf.yaml:14: route "uploads" has no match.hosts but route "api" does — add match.hosts to route "uploads", repeating the host for multiple routes is fine, or add "host" at the top level if all routes share the same host
Global section¶
Global defines defaults applied to every route unless the route overrides.
global:
mode: blocking # "blocking" (default) or "detect_only"
disable: [] # canonical protection names disabled everywhere
enable: [] # opt-in protections to turn on everywhere
# ── What the route accepts ────────────────────────────────
accept:
methods: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
content_types: [] # empty = all; values are MIME types; gates which parsers run
max_body_size: 10MB
max_url_length: 8192
max_header_size: 16KB
max_header_count: 100
require_host_header: true
# ── How the WAF inspects ──────────────────────────────────
inspection:
evaluation_timeout: 50ms # context deadline for rule evaluation
max_inspect_size: 128KB # bytes of non-file body evaluated by rules
max_memory_buffer: 128KB # spool to disk above this
decompression_ratio_limit: 100 # reject if uncompressed/compressed > ratio
json_depth: 20 # max nesting depth for JSON bodies
json_keys: 1000 # max key count in JSON objects
xml_depth: 20 # max nesting depth for XML bodies (only if XML accepted)
xml_entities: 100 # max entity expansions (only if XML accepted)
# ── File uploads ──────────────────────────────────────────
# Only active if content_types includes multipart/form-data
multipart:
file_limit: 10
file_size: 10MB
allowed_types: [] # empty = all; values are MIME types
double_extension: true
# ── Wire-level behavior ──────────────────────────────────
protocol:
slow_request_header_timeout: 10s
slow_request_min_rate_bps: 1024
http2_max_concurrent_streams: 100
http2_max_continuation_frames: 32
http2_max_decoded_header_bytes: 65536
# ── What the response carries ─────────────────────────────
response_headers:
inject: {} # overrides per header (see protections catalog)
strip_extra: [] # additional response headers to strip
# ── API contract ──────────────────────────────────────────
openapi:
shadow_api_logging: true # log undeclared paths even when openapi-path-not-in-spec is disabled
# ── Rate limiting ─────────────────────────────────────────
# Off unless this block is present. Replaced wholesale by a route-level block.
rate_limit:
requests: 100 # required
window: 1s # required; parsed duration, e.g. "1s", "30s", "5m"
source:
type: ip # "ip" or "header"
key: "" # required when type == "header"
backend:
type: memory # only "memory" is supported today
max_keys: 100000 # LRU cap on tracked keys
ttl: 10m # idle-key eviction window
Global field reference¶
| Path | Type | Default | Validation |
|---|---|---|---|
global.mode |
enum | blocking |
one of blocking, detect_only |
global.disable |
[]string | [] |
every entry must resolve to a registered canonical name (L1 family, L2 bucket, or leaf) |
global.enable |
[]string | [] |
every entry must resolve to a registered canonical name; takes precedence over disable for more-specific names |
global.accept.methods |
[]string | standard 7 | each must be a valid HTTP method |
global.accept.content_types |
[]string | [] (all) |
each must be valid MIME type syntax |
global.accept.max_body_size |
byte size | 10MB |
> 0, <= 1GB |
global.accept.max_url_length |
int | 8192 |
>= 512, <= 65536 |
global.accept.max_header_size |
byte size | 16KB |
>= 4KB, <= 1MB |
global.accept.max_header_count |
int | 100 |
>= 10, <= 1000 |
global.accept.require_host_header |
bool | true |
— |
global.inspection.evaluation_timeout |
duration | 50ms |
>= 10ms |
global.inspection.max_inspect_size |
byte size | 128KB |
> 0, <= 10MB |
global.inspection.max_memory_buffer |
byte size | 128KB |
> 0, <= 10MB |
global.inspection.decompression_ratio_limit |
int | 100 |
>= 1 |
global.inspection.json_depth |
int | 20 |
>= 1, <= 1000 |
global.inspection.json_keys |
int | 1000 |
>= 1, <= 100000 |
global.inspection.xml_depth |
int | 20 |
>= 1, <= 1000 |
global.inspection.xml_entities |
int | 100 |
>= 0, <= 10000 |
global.multipart.file_limit |
int | 10 |
>= 1 |
global.multipart.file_size |
byte size | 10MB |
> 0 |
global.multipart.allowed_types |
[]string | [] (all) |
MIME type syntax |
global.multipart.double_extension |
bool | true |
— |
global.protocol.slow_request_header_timeout |
duration | 10s |
>= 1s |
global.protocol.slow_request_min_rate_bps |
int | 1024 |
>= 0 |
global.protocol.http2_max_concurrent_streams |
int | 100 |
>= 1 |
global.protocol.http2_max_continuation_frames |
int | 32 |
>= 1 |
global.protocol.http2_max_decoded_header_bytes |
int | 65536 |
>= 4096 |
global.response_headers.inject |
map[string]string | {} |
keys must be canonical response-headers-add-* names from the protection catalog |
global.response_headers.strip_extra |
[]string | [] |
valid HTTP header names |
global.openapi.shadow_api_logging |
bool | true |
— |
global.rate_limit |
object | none (off) | when present, requests, window, and source.type are required — see Rate limiting |
global.rate_limit.requests |
int | — | >= 1 |
global.rate_limit.window |
duration | — | >= 1s; e.g. 1s, 30s, 5m |
global.rate_limit.source.type |
enum | — | one of ip, header |
global.rate_limit.source.key |
string | — | required when source.type is header |
global.rate_limit.backend.type |
enum | memory |
currently must be memory |
global.rate_limit.backend.max_keys |
int | 100000 |
>= 1 |
global.rate_limit.backend.ttl |
duration | 10m |
>= 1s |
Byte sizes accept suffixes: B, KB, MB, GB (powers of 1024). Bare integers are bytes.
Durations use Go's time.ParseDuration syntax: 500ms, 10s, 2m, etc.
Route section¶
routes:
- id: public-api # optional, used as metric label; default: generated from match
match: # optional; if omitted, matches all requests
hosts: [api.example.com] # optional; default: match any host
paths: ["/v1/*"] # optional; default: match any path
upstream: http://backend:8000 # required
upstream_timeout: 30s # optional, default: 30s
rewrite: # optional
strip_prefix: /v1 # remove prefix before forwarding
add_prefix: /api # prepend after stripping
path: /exact/path # full replacement (overrides strip/add)
mode: blocking # override global; optional ("blocking" or "detect_only")
disable: [] # canonical protection names disabled for this route only
enable: [] # opt-in protections to turn on for this route only
accept: # any subset; unspecified fields inherit from global
content_types: [application/json]
methods: [GET, POST]
max_body_size: 50MB
inspection: {} # any subset; unspecified fields inherit from global
multipart: {} # any subset; gated by accept.content_types
response_headers:
inject:
response-headers-add-csp: "default-src 'self'; script-src 'self' https://cdn.example.com"
strip_extra: []
openapi:
spec: /etc/barbacana/specs/public-api.yaml # path relative to config or absolute
strict: true # if true, enforce; if false, detect_only regardless of route
disable: [] # openapi-* leaves to skip
cors: # CORS is opt-in per route
allow_origins: ["https://app.example.com"] # required when cors block is present
allow_methods: [GET, POST]
allow_headers: [Authorization, Content-Type]
expose_headers: []
allow_credentials: false
max_age: 600
error_response: # optional; custom body for blocked requests
body: |
{"error":"blocked","request_id":"{{.RequestID}}","ts":"{{.Timestamp}}"}
rate_limit: # optional; replaces global.rate_limit entirely (no merge)
requests: 20
window: 1s
source:
type: header
key: X-Api-Key
Route field reference¶
| Path | Type | Default | Validation |
|---|---|---|---|
routes[].id |
string | generated from first path | ^[a-z0-9][a-z0-9-]*$, unique per config |
routes[].match |
object | match all | if present, at least one of hosts or paths must be set |
routes[].match.hosts |
[]string | [] (any) |
each a valid hostname or wildcard (*.example.com) |
routes[].match.paths |
[]string | [] (any) |
each starts with / |
routes[].upstream |
URL string | — (required) | valid http:// or https:// URL |
routes[].upstream_timeout |
duration | 30s |
>= 1s, <= 600s |
routes[].rewrite.strip_prefix |
string | none | must start with / |
routes[].rewrite.add_prefix |
string | none | must start with / |
routes[].rewrite.path |
string | none | must start with /; if set, strip_prefix and add_prefix are ignored |
routes[].mode |
string | inherit from global | one of blocking, detect_only |
routes[].disable |
[]string | [] |
canonical names (L1 family, L2 bucket, or leaf) |
routes[].enable |
[]string | [] |
canonical names; more-specific entries override disable |
routes[].accept.* |
inherit from global | see global field reference | |
routes[].inspection.* |
inherit from global | see global field reference | |
routes[].multipart.* |
inherit from global | see global field reference | |
routes[].response_headers.inject |
map | inherit (merged key-wise) | keys are canonical response-headers-add-* names |
routes[].openapi |
object | none (feature off) | when present, spec is required |
routes[].openapi.spec |
filepath | — | required when the openapi: block is present; file must exist and parse as OpenAPI 3.x |
routes[].openapi.strict |
bool | true |
— |
routes[].openapi.disable |
[]string | [] |
openapi-* leaf names |
routes[].cors |
object | none (CORS off) | when present, allow_origins is required |
routes[].cors.allow_origins |
[]string | — | required when the cors: block is present; origins or * (never * with credentials) |
routes[].cors.allow_methods |
[]string | [GET] |
valid HTTP methods |
routes[].cors.allow_headers |
[]string | [] |
valid header names |
routes[].cors.expose_headers |
[]string | [] |
valid header names |
routes[].cors.allow_credentials |
bool | false |
if true, allow_origins must not contain * |
routes[].cors.max_age |
int (seconds) | 600 |
>= 0, <= 86400 |
routes[].error_response.body |
string | none (default JSON body) | Go text/template; only {{.RequestID}} and {{.Timestamp}} are exposed |
routes[].rate_limit |
object | inherit from global.rate_limit (or off) |
when present, replaces the global block — no field-level merging; see Rate limiting |
The disable and enable lists¶
Both lists accept canonical names from the protection catalog — at any of the three levels (L1 family, L2 bucket, leaf). Examples:
sql-injection— L2; disables every SQLi technique on this routesql-injection-union-select— leaf; disables only that techniqueresponse-headers-add-csp— leaf; skips CSP header injection (or, inenable:, opts in)response-headers-remove-server— leaf; keeps the upstream'sServerheaderopenapi-body-mismatch— leaf; skips body-schema validation but keeps path/method/param validation
Validation rejects any entry that does not resolve to a registered canonical name. The error message lists the misspelled entry and a suggestion if one is close (Levenshtein ≤ 2).
Route-level disable:/enable: is merged with global lists into a single effective set per route. Resolution rule: more specific wins. A leaf in enable: overrides its L2 or L1 in disable:, and the reverse for disable:. See the Tuning protections page for worked examples.
Content-type gating¶
accept.content_types controls which parsers run for a route:
- If empty (default), all parsers are active.
- If set to
[application/json], only the JSON parser runs. XML parsing, multipart parsing, and form-urlencoded parsing are all skipped. XML-related inspection knobs (xml_depth,xml_entities) have no effect. - A POST with a Content-Type not in the accept list is rejected with
415 Unsupported Media Type. - The
multipartsection is only active ifcontent_typesincludesmultipart/form-data.
This is both a security control (rejecting unexpected content types) and a performance optimization (skipping unnecessary parsers).
Audit log¶
Stdout emission of audit events is unconditional — there is no off switch. The audit_log block selects the wire schema only.
| Field | Default | Valid values |
|---|---|---|
audit_log.format |
ocsf |
ocsf, ecs |
The choice is process-wide and applies to every audit document for the lifetime of the process. Switching formats at runtime requires a config reload; one running process never mixes the two.
ocsf emits OCSF v1.2.0 (HTTP Activity event class, class_uid: 4002). ecs emits ECS 8.x. Both formats also carry a vendor barbacana.* namespace with matched_protections, matched_rules, and cwe — see the audit log reference for full document examples and the field-mapping table.
Tracing¶
Distributed tracing is opt-in. With the block absent or enabled: false, no exporter is created and no OTLP traffic ever leaves the process.
tracing:
enabled: false # default; flip to true to ship traces
protocol: grpc # grpc (default) or http (== http/protobuf)
endpoint: "" # falls back to OTEL_EXPORTER_OTLP_ENDPOINT
insecure: true # default; set false to require TLS to the collector
headers: # optional, e.g. authentication
authorization: "Api-Token <secret>"
timeout: "" # optional, e.g. 5s; >= 100ms when set
service:
name: "" # defaults to "barbacana" when empty
namespace: ""
version: "" # defaults to the build's internal version when empty
| Field | Default | Validation |
|---|---|---|
tracing.enabled |
false |
bool |
tracing.protocol |
grpc |
one of grpc, http, http/protobuf |
tracing.endpoint |
"" (use env) |
non-empty wins over OTEL_EXPORTER_OTLP_ENDPOINT; empty defers to env |
tracing.insecure |
true |
bool |
tracing.headers |
none | string → string map |
tracing.timeout |
none | duration string parseable by Go's time.ParseDuration; >= 100ms when set |
tracing.service.name |
"barbacana" |
string |
tracing.service.namespace |
none | string |
tracing.service.version |
from build | string |
A subset of the standard OTLP exporter env vars are honoured as fallback when the corresponding YAML field is empty: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TIMEOUT, and OTEL_RESOURCE_ATTRIBUTES (for any attribute other than service.name and service.version). YAML wins when both are set. OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_TRACES_SAMPLER, and OTEL_TRACES_SAMPLER_ARG are not currently consulted — use the matching YAML fields. The default sampler is ParentBased(AlwaysSample); for high-volume deployments, do tail sampling at your OTLP collector layer.
When tracing is enabled, audit log entries also carry the active trace and span IDs so SIEM events can be pivoted to the corresponding distributed trace. See tracing for the span model and a worked Jaeger example.
Route matching precedence¶
When a request arrives:
- If a route has no
matchblock, it matches everything. - Filter routes whose
match.hostsmatches the requestHostheader. Emptyhostsmatches any host. - Among survivors, select the route whose
match.pathshas the most specific match. - Specificity: literal path > longer prefix > shorter prefix.
/v1/users/profilebeats/v1/users/*beats/v1/*beats/*. - Ties are resolved by source order (earlier wins). The compiler warns when ties exist.
- If no route matches, the request is rejected with
404 Not Found. There is no default route — explicit routing is required.
Host matching:
- Exact:
api.example.com - Suffix wildcard:
*.example.commatchesfoo.example.combut notexample.com - Case-insensitive
Path matching uses glob syntax: * matches a single segment, ** matches any number of segments. Trailing slashes are normalized.
Example 1: minimal (Mode 3, plain HTTP behind a load balancer)¶
Everything else is defaulted. port defaults to 8080 because no host is set and no route uses match.hosts. metrics_port and health_port stay at 0 (disabled) — audit logs on stdout are the only observability. Every protection is active in blocking mode. The five default-on security headers are injected with their built-in values. All canonical strip headers removed. All content types accepted. All parsers active.
Example 2: multi-route with per-team overrides (Mode 1, single host auto-TLS)¶
version: v1alpha1
host: example.com # single host, auto-TLS on :443 and :80→:443 redirect
data_dir: /var/lib/barbacana # persistent TLS/ACME state
metrics_port: 9090 # opt-in: expose Prometheus /metrics
health_port: 8081 # opt-in: expose /healthz and /readyz
global:
mode: blocking # switch whole instance to blocking mode
routes:
- id: public-api
match:
paths: ["/v1/*"]
upstream: http://api-backend:8000
accept:
content_types: [application/json]
methods: [GET, POST, PUT, DELETE]
rewrite:
strip_prefix: /v1
openapi:
spec: /etc/barbacana/specs/public-api.yaml
- id: admin
match:
paths: ["/admin/*"]
upstream: http://admin-backend:8000
accept:
content_types: [application/json]
enable:
- response-headers-add-csp
- response-headers-add-coep
response_headers:
inject:
response-headers-add-csp: "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; upgrade-insecure-requests"
response-headers-add-coep: "require-corp"
cors:
allow_origins: ["https://example.com"]
allow_credentials: true
- id: legacy-php
match:
paths: ["/legacy/*"]
upstream: http://legacy:80
rewrite:
strip_prefix: /legacy
add_prefix: /app
disable:
- php-injection # legacy app trips on its own PHP-ish params (L2)
- http-compliance-null-bytes # legacy binary protocol uses \x00 markers
mode: detect_only # keep logging but don't break the legacy app
Example 3: extensive overrides (Mode 2, multi-host auto-TLS)¶
version: v1alpha1
data_dir: /var/lib/barbacana # persistent TLS/ACME state
metrics_port: 9090 # opt-in: expose Prometheus /metrics
health_port: 8081 # opt-in: expose /healthz and /readyz
global:
mode: blocking
disable:
- scanner-detection # noisy across the whole fleet
accept:
methods: [GET, POST, PUT, DELETE]
max_body_size: 50MB
inspection:
json_depth: 15
response_headers:
inject:
response-headers-add-csp: "default-src 'self' https://assets.example.com"
response-headers-add-hsts: "max-age=31536000"
strip_extra:
- X-Custom-Backend-Id
routes:
- id: uploads
match:
hosts: [uploads.example.com]
paths: ["/upload/*"]
upstream: http://uploads:8000
accept:
content_types: [multipart/form-data]
max_body_size: 500MB
multipart:
file_limit: 50
file_size: 100MB
allowed_types:
- image/png
- image/jpeg
- application/pdf
double_extension: true
inspection:
max_inspect_size: 256KB # larger payloads need more inspection buffer
- id: graphql
match:
hosts: [api.example.com]
paths: ["/graphql"]
upstream: http://gql:4000
accept:
content_types: [application/json]
inspection:
json_depth: 40 # GraphQL queries can be deep
json_keys: 5000
- id: webhooks
match:
hosts: [hooks.example.com]
upstream: http://hook-router:8000
accept:
content_types: [application/json, application/x-www-form-urlencoded]
disable:
- response-headers-add-csp # webhooks never render HTML
enable:
- response-headers-add-cache-control
response_headers:
inject:
response-headers-add-cache-control: "no-store"
Validation behaviour¶
All validation runs during --validate and on startup. Errors are emitted as a single list with file path, YAML line number, and a specific message. Example:
waf.yaml:17: unknown protection "sql-injetcion" in route "public-api" disable list (did you mean "sql-injection"?)
waf.yaml:23: global.accept.max_body_size must be <= 1GB, got 2GB
waf.yaml:31: route "admin" cors.allow_credentials is true but allow_origins contains "*"
waf.yaml:45: route "uploads" accept.content_types includes "multipart/form-data" but multipart.file_limit is 0
The binary exits 1 with the error list. No config fragments are ever applied when validation fails.