Rhai policy
This guide is an end-to-end lab for Rhai for rules — two request-hook patterns where scripts add value over built-in actions alone. Each example uses self-contained YAML, a .rhai script, and dig checks. API detail is described in Host API overview, Rhai for rules, and Hooks and phases.
Prerequisites: Conduit on your PATH (Install and run); dig; optional dnsmasq as a loopback upstream mock (First query). Read Rules and actions for selectors and built-in actions first.
What you will verify
| Example | Hook | Outcome |
|---|---|---|
| Blocklist drop | Request | CSV block → query drops (no DNS reply); conduit_user_block_hits increments; other names in scope forward |
| CSV pool routing | Request | Lookup table maps qname → pool; dig shows different answers per row |
Use a working directory per example (for example ~/conduit-lab/blocklist/ and ~/conduit-lab/routing/). Paths in config resolve relative to the config file directory — see Config file — path resolution.
Example 1 — Blocklist drop (request hook)
Lookup table on the request hook: match qname against a CSV, increment a custom user metric on blocks, then drop before Lookup. The client gets no DNS reply. Event export still emits a query dnstap frame after request rules when sinks match — use tag_required or filters to scope blocked traffic, or rely on a user metric for block counters.
flowchart LR
Q[Client query] --> Req[Request rules]
Req -->|blocklist match + drop| Drop[Drop — no reply]
Req -->|allow| Lookup[Lookup]
1. Layout
Create three files beside each other:
| File | Role |
|---|---|
conduit.yaml |
Listeners, pool, data_sources, metrics, rules, rhai: |
data/blocklist.csv |
qname → action |
scripts/blocklist.rhai |
lookup, metrics.inc, drop |
data/blocklist.csv:
qname,action
evil.bad.example.,block
good.bad.example.,allow
scripts/blocklist.rhai:
if lookup("blocklist", txn.question().qname) == "block" {
metrics.inc("block_hits", 1);
txn.drop_query();
}
Custom user metric: metrics.inc("block_hits", 1) registers a script-defined counter at snapshot compile and exports it as conduit_user_block_hits when metrics are enabled. Increments flush before drop intent is resolved, so blocked queries still count even though the client gets no DNS reply. See User metrics.
Soft drop_query() sets drop intent at the end of the rule pass. See Transaction API — Outcomes.
2. Config
conduit.yaml:
schema_version: 1
listeners:
listeners:
- address: "127.0.0.1:15353"
protocol: udp
pools:
- name: default
backends:
- address: "127.0.0.1:5300"
rhai:
max_operations: 10000
max_call_depth: 32
hook_timeout_ms: 50
data_sources:
- name: blocklist
type: csv
path: data/blocklist.csv
key_column: qname
value_column: action
metrics:
enabled: true
base: standard
prometheus:
listen_address: "127.0.0.1:9090"
path: /metrics
rules:
match_mode: first_match
rules:
- name: blocklist-check
hook: request
selectors:
- type: qname_suffix
value: "bad.example."
actions:
- type: rhai
value: scripts/blocklist.rhai
Metrics (minimum for scrape): metrics.enabled: true turns on recording; base: standard (default when enabled and unset) includes script-discovered user metrics with collect+emit on. On base: minimal, list each script metric under user_metrics with collect: true when you want them recorded (otherwise increments no-op with a warning). prometheus.listen_address exposes GET /metrics for local scrape — no OTLP block required for this lab.
Validate:
conduitctl validate --file conduit.yaml
3. Run upstream and Conduit
Terminal A — upstream on 127.0.0.1:5300 (replace 8.8.8.8 with a resolver you can reach):
export UPSTREAM_DNS="8.8.8.8"
dnsmasq -d \
--port=5300 \
--bind-interfaces \
--listen-address=127.0.0.1 \
--server="$UPSTREAM_DNS" \
--no-hosts --no-resolv --log-queries --log-facility=-
Terminal B:
conduit /path/to/conduit-lab/blocklist/conduit.yaml
4. Query and verify
Blocked name (CSV block) — expect dig timeout (no reply):
dig @127.0.0.1 -p 15353 +time=3 +tries=1 evil.bad.example. A
Custom block counter — after at least one blocked query, scrape Prometheus format and look for conduit_user_block_hits:
curl -sS http://127.0.0.1:9090/metrics | grep '^conduit_user_block_hits'
Expect: a line with value 1 or higher (one increment per blocked query). Allowed and out-of-scope queries do not increment this counter.
Allowed name in the same suffix (CSV allow) — expect NOERROR and an answer:
dig @127.0.0.1 -p 15353 +time=3 +tries=1 good.bad.example. A
Name outside the rule’s qname_suffix selector never runs the script — Conduit forwards normally:
dig @127.0.0.1 -p 15353 +time=3 +tries=1 example.com A
What you verified: Data sources grant model, request-hook Rhai, policy drop vs forward, and a custom user metric on blocks (conduit_user_block_hits). Deeper behavior: Runtime and concurrency — Query outcomes, User metrics, Troubleshooting — no client response.
Example 2 — CSV pool routing (request hook)
A routing table maps many qnames to pool names. lookup + txn.set_pool scales better than one declarative rule per name — the pool for each qname lives in the CSV, not in repeated YAML selectors.
Built-in set_pool on a rule is enough when pool choice is fixed (one suffix → one pool). Rhai earns its place when the mapping is data-driven and changes often (reload the CSV, not a wall of rules).
flowchart LR
Q[Client query] --> Req[Request rules + Rhai]
Req -->|lookup premium| P1[Route → premium · :5301]
Req -->|lookup standard| P2[Route → standard · :5300]
P1 --> A1[Answer 192.0.2.99]
P2 --> A2[Answer 192.0.2.10]
1. Layout
| File | Role |
|---|---|
conduit.yaml |
Two pools, data_sources, request rule |
data/routing.csv |
qname → pool name |
scripts/route-by-table.rhai |
lookup + set_pool |
data/routing.csv:
qname,pool
app-a.customer.example.,premium
app-b.customer.example.,standard
scripts/route-by-table.rhai:
let pool = lookup("routing", txn.question().qname);
if pool != "" {
txn.set_pool(pool);
}
On a CSV miss, the script leaves pool choice unchanged — Conduit uses the default pool for that query.
2. Config
conduit.yaml:
schema_version: 1
listeners:
listeners:
- address: "127.0.0.1:15353"
protocol: udp
pools:
- name: standard
backends:
- address: "127.0.0.1:5300"
- name: premium
backends:
- address: "127.0.0.1:5301"
rhai:
max_operations: 10000
max_call_depth: 32
hook_timeout_ms: 50
data_sources:
- name: routing
type: csv
path: data/routing.csv
key_column: qname
value_column: pool
rules:
match_mode: first_match
rules:
- name: route-by-table
hook: request
selectors:
- type: qname_suffix
value: "customer.example."
actions:
- type: rhai
value: scripts/route-by-table.rhai
Pool names in the CSV (premium, standard) must match pools: entries exactly. Validate:
conduitctl validate --file conduit.yaml
3. Run upstreams and Conduit
Run two loopback upstream mocks — one per pool — with different answers for the same qnames so routing is obvious:
Terminal A — standard pool on 127.0.0.1:5300:
dnsmasq -d \
--port=5300 \
--bind-interfaces \
--listen-address=127.0.0.1 \
--no-hosts --no-resolv --log-queries --log-facility=- \
--address=/app-b.customer.example/192.0.2.10
Terminal B — premium pool on 127.0.0.1:5301:
dnsmasq -d \
--port=5301 \
--bind-interfaces \
--listen-address=127.0.0.1 \
--no-hosts --no-resolv --log-queries --log-facility=- \
--address=/app-a.customer.example/192.0.2.99
Terminal C:
conduit /path/to/conduit-lab/routing/conduit.yaml
4. Query and verify
Premium row in the CSV — expect 192.0.2.99 (backend 127.0.0.1:5301):
dig @127.0.0.1 -p 15353 +short app-a.customer.example A
Standard row — expect 192.0.2.10 (backend 127.0.0.1:5300):
dig @127.0.0.1 -p 15353 +short app-b.customer.example A
Name under the suffix but missing from the CSV — expect the default pool (standard, first in pools:). Add rows to the CSV and reload to extend routing without new YAML rules.
Name outside customer.example. — the script never runs; routing follows default pool behavior only.
What you verified: data-driven pool choice on the request hook — the pattern in Data sources — Pool or egress map. For fixed SERVFAIL failover to one backup pool, use declarative set_retry_pool + retry instead — Retries and transactions — Declarative examples. Method reference: Transaction API — Routing.
Reload and script edits
Rule and Rhai changes load into the runtime snapshot on conduitctl reload or SIGHUP for new queries — no process restart required. In-flight transactions keep the policy they started with.
After editing conduit.yaml, a script, or a CSV under data/:
conduitctl validate --file conduit.yaml
# edit the file Conduit was started with, then:
conduitctl reload
Without control: at process start, use SIGHUP instead — see Control plane workflows.
Compile-time checks (unknown lookup table name, Rhai syntax, data_sources read errors) fail validate and block reload. Sandbox limits cap script cost per hook.
Related topics
- Rhai for rules — when to use scripts vs built-in actions
- Declarative failover — SERVFAIL / timeout failover without Rhai
- Rule action order — soft vs hard drop and action-list order
- Data sources — CSV / CIDR formats and grant model
- Rhai lookups —
lookup()/lookup_ip()surface - Retries and transactions — limits and pool lifecycle
- Rules and actions — selectors, action order, validation
- Control plane workflows — reload, apply, export
- Event export and dnstap — tag-gated export (
set_tag+tag_required) - Guides — other walkthroughs