Constable Documentation
Constable is a single-binary Go reverse proxy with WAF-like request inspection. It sits in front of one or more upstream targets and blocks or logs requests matching configurable regex rules applied to URLs, headers, and request/response bodies — then inspects, rewrites, and header-hardens the response on the way back.
The proxy binary is stdlib-only — zero external dependencies. Config is hot-reloaded from disk every 3 seconds (no restart for rules, limits, TLS domains, peers, cache, and more), and can optionally be pulled from a remote GitHub URL.
What's in the box #
Every capability below is configured in a single config.json. Enable only what you need; everything is off by default unless noted.
Start with the Quick Start, then follow the Guided Setup Path — the recommended progression of start minimal → run in observe mode → review → promote rules to blocking. This reference page documents every field exhaustively.
Quick Start #
Two things are required: where to listen and where to forward.
1. Install
sudo apt install ./constable_<version>_amd64.deb
sudo systemctl start constable
The package installs the binary to /usr/bin/constable and its config to /etc/constable/config.json. The config file is hot-reloaded every 3 seconds whenever it changes — no restart needed. See Install from .deb for the full package layout.
2. The bare-minimum config.json
{
"listen_addr": "127.0.0.1:8080",
"target_url": "http://127.0.0.1:9000"
}
That's a working reverse proxy. Every request to 127.0.0.1:8080 is forwarded to your backend. A health endpoint is available at /healthz (bypasses auth/WAF, rate-limited).
Environment variables for config loading
| Variable | Description |
|---|---|
CONSTABLE_CONFIG_PATH | Path to a local config file (overrides the default config.json location). |
CONSTABLE_REMOTE_CONFIG_URL | Remote URL to poll for config — see Remote Config. |
Keep listen_addr on 127.0.0.1 until you've finished tuning, then move to 0.0.0.0 (or put it behind your existing edge) when you go live.
Guided Setup Path #
The recommended progression: start minimal → observe with learning → review what it found → promote rules to blocking → layer on the rest.
Never start in blocking mode on day one. Run in detect/observe first, look at what the proxy would have done against your real traffic, and only then turn on enforcement. This avoids blocking legitimate users while you tune.
1. Add observability early #
Turn on the metrics endpoint now — you'll need it to see what the learning modes are doing. Keep it local-only so it isn't exposed to the world:
{
"listen_addr": "127.0.0.1:8080",
"target_url": "http://127.0.0.1:9000",
"metrics_addr": "127.0.0.1:9090",
"metrics_local_only": true,
"log_file": "/var/log/constable/constable.log",
"log_format": "json"
}
curl -s http://127.0.0.1:9090/metrics gives you Prometheus counters; json log format is recommended for anything you'll grep or ship to a log pipeline.
2. Run both learning systems in observe mode #
Constable has two complementary learning systems. Run both, in observe/log mode first, then review.
| System | What it learns | Best for |
|---|---|---|
| Learn Mode | The query-parameter names your app uses → a file of mode:"log" rules to review | Bootstrapping a static rule set from scratch |
| Adaptive Learning | A live behavioral profile + a risk score per request | Ongoing anomaly detection that adapts over time |
Enable Learn Mode and Adaptive Learning in shadow, then drive representative, legitimate traffic through the proxy (real users, a staging suite, or a crawler).
3. Detect: review what learning found #
# Learn Mode candidate rules firing
grep '"event":"DETECT"' constable.log | grep 'learned-'
# Adaptive: what shadow mode WOULD have blocked
curl -s http://127.0.0.1:9090/metrics | grep proxy_adaptive_would_block_total
grep '"event":"ADAPTIVE"' constable.log
You are looking for two things: the would-block counter rises with known-bad traffic, and stays at (or near) zero for legitimate traffic.
4. Apply: promote rules to blocking #
Only after review, copy trusted rules from learned-rules.json into url_rules/header_rules and change "mode": "log" to "mode": "block"; then disable Learn Mode. Promote Adaptive Learning to enforce via a conditional rule with trigger_on: "adaptive_score".
5. Going to production — checklist #
listen_addrbound where you want it (behind your edge/firewall as appropriate).log_fileset,log_format: "json", rotation configured.metrics_addrset withmetrics_local_only: true.- Learn Mode disabled after rules were promoted.
- Adaptive Learning ran in
shadowfor a full traffic cycle beforeenforce. - Rules promoted to
blockwere watched inlogfirst. - Secrets supplied via
$ENV{VAR}expansion, not hard-coded. - Running under systemd with restart-on-failure.
Remote Config from GitHub #
The proxy can poll a remote URL (e.g. a raw GitHub file) and reload automatically when the content changes.
| Variable | Required | Default | Description |
|---|---|---|---|
CONSTABLE_REMOTE_CONFIG_URL | yes | — | Full URL to poll. Must be https — plaintext is refused at startup unless the allow-http flag is set. Redirects to internal/loopback/metadata addresses are blocked. |
CONSTABLE_REMOTE_CONFIG_TOKEN | no | — | GitHub token for private repos (sent as Authorization: token <value>). Also raises the rate limit from 60 to 5,000 req/hour. |
CONSTABLE_REMOTE_CONFIG_INTERVAL | no | 60 | Poll interval in seconds. |
CONSTABLE_REMOTE_CONFIG_ALLOW_HTTP | no | — | Set to 1 to allow a plaintext http:// URL (insecure; logs a loud warning). |
export CONSTABLE_REMOTE_CONFIG_URL="https://raw.githubusercontent.com/you/repo/main/proxy.json"
export CONSTABLE_REMOTE_CONFIG_TOKEN="ghp_xxxxxxxxxxxx" # optional, for private repos
export CONSTABLE_REMOTE_CONFIG_INTERVAL=60 # optional, default 60
./constable
How it works
- The local
config.jsonis always loaded first as the initial/fallback config. - On startup the proxy fetches the remote URL; on success it applies the remote config and writes it back to the local file.
- Every interval it polls using an
ETag/If-None-Matchheader. GitHub returns304 Not Modifiedwhen unchanged — free requests that don't count against the rate limit. - When the file changes, the new config is validated and applied live, the local cache file is updated, and a reload line is logged.
- If the remote fetch fails (network error, non-2xx, invalid JSON), the proxy logs a warning and continues with the current config unchanged.
Set "remote_config_silent": true to suppress routine poll / 304 lines; errors, 429 rate-limits, and 200 config applied always log. This setting is hot-reloadable.
Install from .deb (Debian/Ubuntu) #
Constable is delivered as prebuilt .deb packages for amd64 and arm64.
# install the package you were provided (match your host architecture)
sudo apt install ./constable_<version>_amd64.deb
sudo $EDITOR /etc/constable/config.json # seeded from the shipped example on first install
sudo systemctl start constable
What the package lays down
| Path | Purpose |
|---|---|
/usr/bin/constable | The proxy binary. |
/etc/constable/config.json | Live config. Seeded from the example on first install; never overwritten on upgrade. |
/etc/constable/config.json.example | Reference schema, refreshed every upgrade (dpkg conffile). |
/lib/systemd/system/constable.service | systemd unit. Runs as the constable user with CAP_NET_BIND_SERVICE. |
/var/lib/constable/ | State dir (good place for the Let's Encrypt cache). |
/var/log/constable/ | Log dir. |
The unit sets CONSTABLE_CONFIG_PATH=/etc/constable/config.json and is enabled on install. Upgrades preserve config.json and try-restart the service. apt purge removes the system user and config dir.
Running as a systemd Service #
If you can't use the .deb directly (non-Debian distro, custom paths), this manual layout is what the package effectively does. The proxy is a single static binary, so it runs cleanly under systemd. Extract the binary and example config from the package you were provided with dpkg-deb -x constable_<version>_amd64.deb ./extracted.
Lay out files
sudo useradd --system --home /opt/constable --shell /usr/sbin/nologin constable
sudo mkdir -p /opt/constable /var/log/constable /var/lib/constable
sudo install -m 0755 constable /opt/constable/proxy
sudo install -m 0640 config.json.example /opt/constable/config.json # then edit it
sudo chown -R constable:constable /opt/constable /var/log/constable /var/lib/constable
Keep config.json group-readable by the constable user (mode 0640, group constable). A root-owned 0600 file will make the service fail to read it.
systemd unit — /etc/systemd/system/constable.service
[Unit]
Description=Constable
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=constable
Group=constable
WorkingDirectory=/opt/constable
ExecStart=/opt/constable/proxy
EnvironmentFile=-/etc/constable.env
Environment=CONSTABLE_CONFIG_PATH=/opt/constable/config.json
Restart=on-failure
RestartSec=2s
LimitNOFILE=1048576
# Allow binding 80/443 as non-root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ReadWritePaths=/var/log/constable /var/lib/constable /opt/constable
[Install]
WantedBy=multi-user.target
The [Install] section is required — without it systemctl enable refuses the unit.
Secrets in an env file (optional)
Only needed if you've enabled the AI report, SMTP, or peer sync:
sudo tee /etc/constable.env >/dev/null <<'EOF'
ANTHROPIC_API_KEY=sk-ant-...
SMTP_PASSWORD=...
PEER_SYNC_KEY=...
EOF
sudo chmod 0600 /etc/constable.env
sudo chown root:constable /etc/constable.env
Start and enable
sudo systemctl daemon-reload
sudo systemctl enable --now constable
sudo systemctl status constable
sudo journalctl -u constable -f # live logs
Edit the config and the proxy hot-reloads within ~3 seconds — no restart for rules, rate limits, peers, cache, gzip, or Let's Encrypt domains. systemctl restart only when you swap the binary or change values in the env file.
Request Processing Pipeline #
Every incoming request passes through these checks in order. The first failure stops processing and returns the configured error response (default 403).
Incoming request
│
1. Generate / propagate Request ID
2. Health check endpoint (/healthz)
3. ACME HTTP-01 challenge (Let's Encrypt)
4. HTTP → HTTPS redirect
5. Acquire worker slot → 503 on timeout
6. Rate limit check (per-IP) → 429 if exceeded
7. IP allow / block list
8. GeoIP check
9. Botnet detection
Layer 1: IP reputation blocklists
Layer 2: Behavioral (error rate + path scan)
Layer 3: User-agent fingerprinting
10. Conditional rules (per-endpoint threshold blocks)
11. Authentication (Basic / API key / JWT)
12. HTTP method check
13. Header count and size limits
14. URL rules
15. Header rules
16. CVE detection — URL + headers (named signatures, stack-scoped)
17. Per-path rules
18. Adaptive scoring (shadow: log only; enforce: feed conditional rules)
19. Read request body → 413 if over max_body_bytes
20. Body rules → 408 on regex timeout
21. CVE detection — request body
22. Select upstream (load balancer, health-aware)
│ ▼ Forward to upstream ▼
23. Passive stack fingerprint + inspect response body
24. Apply response rewrites
25. Inject security headers / strip removed headers
│
Record against conditional + adaptive windows → Return to client
"mode": "log" emits a [DETECT] line but does not block — the request continues. "mode": "null" silently drops the TCP connection without any response (mirrors iptables DROP), emitting [DROP]. CVE detections emit [CVE_DETECT] / [CVE_BLOCK]. Adaptive scoring never blocks inline — in shadow it only emits [ADAPTIVE].
Multi-Domain Hosting #
One proxy can serve multiple distinct domains/subdomains, each with its own upstreams, rules, auth, methods, gzip, cache, and security headers. Add a domains map where each key is the Host header to match (lowercased, port stripped).
{
"listen_addr": ":443",
"tls": { "listen_addr": ":443" },
"lets_encrypt": { "enabled": true, "email": "ops@example.com", "cache_dir": "/var/lib/constable/acme" },
"url_rules": [
{ "label": "deny .env (global)", "pattern": "(?i)\\.env($|\\?)" }
],
"domains": {
"api.example.com": {
"upstreams": [{ "url": "http://api-backend:8080" }],
"url_rules": [{ "label": "api: block /admin", "pattern": "(?i)^/admin" }],
"auth": { "api_key": { "enabled": true, "header": "X-API-Key", "keys": ["$ENV{API_KEY_PROD}"] } }
},
"www.example.com": {
"target_url": "http://www-backend:8080",
"gzip": { "enabled": true, "level": 6 }
}
}
}
Per-Vhost vs Global #
Per-vhost (in domains.*) | Stays global (top-level only) |
|---|---|
target_url / upstreams / load_balance / preserve_host | rate_limit |
url_rules / header_rules / body_rules / response_body_rules | botnet_detection |
path_rules | conditional_rules |
allowed_methods | geoip |
auth | peers |
security_headers / remove_response_headers | allowed_ips / blocked_ips |
gzip / cache | tls / listen_addr / max_workers |
inspect_get_body / max_body_bytes / allowed_redirect_hosts | lets_encrypt (but SAN list auto-includes vhost keys) |
response_rewrites / upstream_stack | health_check / trusted_proxies / ai_report |
Stateful subsystems (rate-limit, botnet, conditional rules, peer sync, GeoIP) intentionally stay global — they track cross-host attack signal. CVE detection is global too, but its upstream_stack declaration is per-vhost.
Merge precedence #
Per-request merge order is per-path rules > per-domain block > top-level config. Within the per-domain block, each overridable field uses nil = inherit, non-nil = override:
- Slice fields (
url_rules, etc.): absent /null⇒ inherit top-level. A non-nil slice (even[]) replaces the top-level slice for that vhost. - Pointer fields (
gzip,cache,auth, …): absent ⇒ inherit; present ⇒ override. security_headers: absent map ⇒ inherit; present map ⇒ override (maps are not merged).
Requests whose Host doesn't match any domains key fall through to the top-level config, which acts as the default vhost — so existing single-host configs work unchanged. To reject unknown hosts, leave target_url and upstreams empty at the top level and the proxy returns 502 for unmatched hosts.
Core Settings #
| Field | Type | Default | Description |
|---|---|---|---|
listen_addr | string | required | Address to listen on, e.g. ":80" or "127.0.0.1:8000". |
target_url | string | — | Single upstream origin, e.g. "http://localhost:8080". Use upstreams for multiple. |
max_workers | int | NumCPU×4 | Max concurrent requests. Excess requests queue until queue_timeout_ms. |
max_procs | int | 0 (all CPUs) | GOMAXPROCS. 0 uses all available CPUs. |
queue_timeout_ms | int | 5000 | Milliseconds to wait for a worker slot before returning 503. |
inspect_get_body | bool | false | Apply body rules to GET requests. |
log_allowed | bool | false | Emit [ALLOW] log lines for requests that pass all checks. |
preserve_host | bool | false | Forward the original Host header to the upstream instead of rewriting it. |
Blocking Behavior #
| Field | Type | Default | Description |
|---|---|---|---|
block_status_code | int | 403 | HTTP status code returned when a request is blocked. |
block_message | string | "Blocked by proxy policy" | Response body text when a request is blocked. |
block_x_forwarded_for | bool | false | Also apply IP allow/block checks to X-Forwarded-For / X-Real-IP. Only honored when the value parses as a real IP and arrives via a configured trusted_proxies hop. |
trusted_proxies | []string | [] | IPs/CIDRs of trusted upstream proxies allowed to set X-Forwarded-For. |
The proxy is the sole authority for the X-Forwarded-* headers it sends to the backend: it overwrites X-Forwarded-For with its own trusted client-IP view, sets X-Forwarded-Proto/X-Forwarded-Host from its own state, and strips client-supplied Forwarded, X-Forwarded-Scheme, X-Original-URL/-Host, and X-Rewrite-URL. If you place this proxy behind another L7 hop, list that hop in trusted_proxies.
IP Allow / Block Lists #
Exact IPs and CIDR ranges are both supported. If allowed_ips is non-empty, all IPs not in the list are blocked. The allow list takes precedence over the block list.
| Field | Type | Default | Description |
|---|---|---|---|
allowed_ips | []string | [] | If non-empty, only these IPs/CIDRs are allowed through. |
blocked_ips | []string | [] | These IPs/CIDRs are always blocked. |
{
"allowed_ips": ["203.0.113.5", "198.51.100.0/24"],
"blocked_ips": ["192.0.2.100"]
}
allowed_ips traffic takes precedence and bypasses adaptive scoring.
Rate Limiting #
Per-IP token bucket rate limiter. Activates as soon as requests_per_second > 0 — there is no separate enabled flag. Stale entries are cleaned up automatically.
| Field | Type | Default | Description |
|---|---|---|---|
rate_limit.requests_per_second | float | 0 (off) | Sustained request rate per IP. |
rate_limit.burst | int | 0 | Maximum burst above the sustained rate. |
rate_limit.cleanup_interval_sec | int | 300 | How often stale per-IP buckets are removed (seconds). |
rate_limit.max_concurrent_per_ip | int | 0 | Caps simultaneously in-flight requests per IP (defeats slowloris-style connection holding). See Production Hardening. |
{
"rate_limit": {
"requests_per_second": 100,
"burst": 200,
"cleanup_interval_sec": 300
}
}
Request Limits #
| Field | Type | Default | Description |
|---|---|---|---|
max_body_bytes | int | 10485760 (10 MB) | Maximum request body size. Returns 413 if exceeded. |
max_header_bytes | int | 0 (unlimited) | Maximum total size of all request headers in bytes. |
max_headers | int | 0 (unlimited) | Maximum number of request headers. |
max_url_length | int | 0 (unlimited) | Maximum URL length in bytes. |
regex_timeout_ms | int | 5000 | Per-request deadline for body regex scanning. Returns 408 on timeout. |
Allowed HTTP Methods #
Global method whitelist. Requests using any method not in the list are blocked.
{
"allowed_methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
}
Logging #
| Field | Type | Default | Description |
|---|---|---|---|
log_format | string | "text" | "text" for human-readable, "json" for structured JSON. |
log_file | string | "" | Path to a log file. Logs go to both stderr and this file. Empty = stderr only. |
log_max_size_mb | int | 0 | Max log file size in MB before rotation. 0 disables rotation. |
log_max_backups | int | 0 | Number of rotated files to keep. 0 keeps all. |
When log_max_size_mb is set, the proxy rotates the log file when it reaches the size. Rotated files are numbered sequentially (constable.log.1, .2, …) with .1 the most recent. Rotation is handled internally — no reload or restart required.
Request ID #
A unique ID is generated for every request (or propagated from an incoming header) and attached to log lines, upstream requests, and responses for end-to-end tracing.
| Field | Type | Default | Description |
|---|---|---|---|
request_id_header | string | "X-Request-ID" | Header name to read from incoming requests and write to upstream requests and responses. |
Rules Overview #
Rules are regex patterns applied to specific parts of the request or response. Each rule has:
| Field | Type | Required | Description |
|---|---|---|---|
label | string | yes | Human-readable name shown in logs. |
pattern | string | yes | RE2 regular expression (max 4,096 chars). |
mode | string | no | "block" (default) rejects with the configured status. "log" emits a [DETECT] line but forwards. "null" silently drops the TCP connection (iptables DROP behavior). |
exceptions | []string | no | If any of these literal strings are present in the input, the rule is skipped entirely. |
url_rules are evaluated against both the raw (percent-encoded) URL and a fully-decoded view, so a payload can't slip past by alphanumeric percent-encoding. Before any rule runs, the proxy rejects (400) requests with encoded path separators at any decoding depth (%2f, %252f, %5c, backslash) or a .. dot-segment — so the form the WAF inspects always matches the path the upstream resolves.
url_rules #
Applied to the full request URL including query string.
{
"url_rules": [
{ "label": "block .env files",
"pattern": "\\.env($|\\?)" },
{ "label": "SQL injection in query string",
"pattern": "(?i)(select|insert|update|delete|drop|union).+(from|into|where|table)" },
{ "label": "path traversal",
"pattern": "(?i)(\\.\\.[\\\\/]|%2e%2e[%2f%5c])" },
{ "label": "sensitive file access",
"pattern": "(?i)\\.(htaccess|htpasswd|git|svn|bak|old|swp)($|[\\?/])" },
{ "label": "debug and status endpoints",
"pattern": "(?i)/(phpinfo|server-status|server-info|elmah\\.axd|actuator|metrics)($|[\\?/])" },
{ "label": "SSRF private IP in query param",
"pattern": "(?i)[?&][^=]+=https?://(127\\.|10\\.|192\\.168\\.|localhost)" },
{ "label": "log access to /admin (detect only)",
"pattern": "(?i)^/admin", "mode": "log" },
{ "label": "block /bad but allow known safe route",
"pattern": "(?i)/bad", "exceptions": ["badtest"] },
{ "label": "silently drop /honeypot",
"pattern": "(?i)^/honeypot", "mode": "null" }
]
}
header_rules #
Applied to the raw Key: Value string for each header.
{
"header_rules": [
{ "label": "block scanner user-agents",
"pattern": "(?i)User-Agent:.*(sqlmap|nikto|nmap|masscan|gobuster|ffuf|wfuzz|nuclei)" },
{ "label": "XSS via Referer or Origin",
"pattern": "(?i)(Referer|Origin|X-Forwarded-Host):.*(
body_rules #
Applied to the request body. Skipped for GET requests unless inspect_get_body is true.
{
"body_rules": [
{ "label": "SQL injection",
"pattern": "(?i)(select\\s.+from\\s|insert\\s+into\\s|drop\\s+table\\s|union\\s+select)" },
{ "label": "XSS script tags",
"pattern": "(?i)
AgileSecOps