Skip to content

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.