Service Discovery Plugins
A policy says allow_http "myservice". If myservice lives in a service
registry rather than in DNS — Consul, Kubernetes, or an internal name service of
your own — two things have to happen that the host’s resolver cannot do, and a
plugin is what does them.
[plugin]path = "/usr/local/libexec/wardcell/wardcell-plugin"Nothing in a policy says a grant is backed by a registry. allow_http "myservice" is written exactly as for a name in DNS, and whether it lives in a
registry is the plugin’s business. Both methods are used if the plugin implements
them, and ignored if it does not.
Which is why naming a plugin in that file is what starts it, for every run —
there is no zones.toml setting that could ask for service discovery, so nothing
in a policy could make it a requirement. See
How wardcell Calls Out.
| Method | Answers | Asked |
|---|---|---|
discovery.resolve_addr | what service this address belongs to | once per unnamed flow the policy would otherwise refuse |
discovery.resolve_name | where this service’s instances are | once per destination the policy allows by name |
Why both directions
Section titled “Why both directions”A flow can arrive with no name. Many registry clients fetch the catalogue
over HTTP, get back literal instance addresses, and connect straight to one. No
lookup ever happens, so nothing names the connection and only an IP or CIDR rule
in the policy could match it. discovery.resolve_addr is how the name comes
back, so the grant the author actually wrote is the one that decides.
A name can exist nowhere the host can see. Some registries have no DNS
interface at all, and are only ever queried over their own API. Consul
(<svc>.service.consul) and Kubernetes cluster DNS do resolve, but from inside
the cluster — and wardcell’s proxy runs on the host, which may not point at that
resolver. discovery.resolve_name is where the addresses come from.
discovery.resolve_addr
Section titled “discovery.resolve_addr”→ {"jsonrpc":"2.0","id":"0191…-3","method":"discovery.resolve_addr", "params":{"addr":"10.12.3.44","port":8080,"zone":"user"}}← {"jsonrpc":"2.0","id":"0191…-3","result": {"name":"us-east-1.myservice","scope":"us-east-1","expires_at":"2026-08-23T18:04:00Z"}}← {"jsonrpc":"2.0","id":"0191…-3","result":null}null is “no opinion”, and it is the common answer — most addresses a sandbox
dials are not registered instances. The flow then stays gated on its address
exactly as it would be with no plugin at all, so null is never read as
permission and a wrong one closes a door rather than opening it.
Asked only where it can change the answer: a flow that already has a name, or one the policy already allows as a literal address, is never looked up. The only flows a lookup is spent on are the ones that would otherwise be refused for having no name.
discovery.resolve_name
Section titled “discovery.resolve_name”→ {"jsonrpc":"2.0","id":"0191…-4","method":"discovery.resolve_name", "params":{"name":"us-east-1.myservice","port":8080,"zone":"user"}}← {"jsonrpc":"2.0","id":"0191…-4","result": {"addresses":["10.12.3.44:8080","10.12.9.7:8080"],"scope":"us-east-1", "expires_at":"2026-08-23T18:04:00Z"}}← {"jsonrpc":"2.0","id":"0191…-4","result":null}null falls back to the host’s own resolver, which is what lets one policy mix
public names and registry names with nothing marking which is which.
An answer replaces the host resolver rather than adding to it: for a name that lives in a registry, what the registry says is the whole truth about where it is.
A flow that arrived at one instance — named by resolve_addr — is dialled at
that instance, and no forward lookup is made for it. Two flows to two
instances of one service each reach the one they chose, so a stateful session is
never reconnected somewhere else, and that holds for
inspected requests too: each instance gets its own
connection pool, so two instances never share a connection. Nothing about the
instance reaches the origin — the request is addressed to the service name it is
virtual-hosted under, over HTTP/1.1 and HTTP/2 alike.
A plugin implementing only resolve_addr still places a destination no flow
arrived at, at an instance it has named.
The service name is what the request keeps being about: the credential wardcell
resolves for it, the TLS certificate it checks, and the Host the origin sees are
all the service, while only the dial address and the connection pool are the
instance. An instance that is denied, or whose claim has expired, refuses the
request — it is never resolved some other way, because the other way reaches a
different instance of the same service, and that is a policy decision stepped
around rather than applied.
Each address carries its own port, and it need not be the port asked about — a
service on 8080 whose instances listen on 31234 is ordinary in Kubernetes. One
exception: an inspected allow_requests destination is
dialled at the port in the request, so a remapped port needs an allow_tcp grant
and is tunnelled rather than inspected. wardcell says so rather than failing
quietly.
Private addresses, and the one rule this relaxes
Section titled “Private addresses, and the one rule this relaxes”A name may not reach loopback, link-local, RFC1918, CGNAT or unique-local space
unless the policy names the address itself. That check is what stops an allowed
public name rebinding onto 10.0.0.5 and pivoting into whatever the host can
reach.
Instance addresses in a service registry are almost always in exactly that space — a Consul node, a Kubernetes pod, an internal host of your own. So a claim relaxes it, and the bounds matter more than the relaxation:
- For the exact
(address, port)pair the plugin named. Never a CIDR, never a neighbouring address, never the same address at another port. - Only while the claim is fresh —
expires_at, with a margin, so a connection is not opened on a waiver about to lapse. - A deny rule still wins. Always, and there is no way for a plugin to override one.
- The policy still has to grant the name. A plugin answers where a service may live, never what may be reached, so it cannot introduce a destination no fragment named.
net { allow_http "us-east-1.myservice" { allow_requests ["* /**"] }}That reaches an ephemeral 10.12.3.44:8080 without granting one byte of
10.0.0.0/8 — which, written as a policy entry, would be an independent grant to
every address the host can reach in that range.
What it widens, stated plainly
Section titled “What it widens, stated plainly”A plugin that answers these becomes a trust anchor for reaching private address space. A compromised or careless one can hand the sandbox an arbitrary internal address by claiming a granted service lives there. The bounds above are what contain it, and every claim is written to the run’s stderr once, when it is first made, so an operator can see which answers widened what.
It is the same trust the plugin already holds for credentials, where it mints the identity a connection presents — a new power for an existing principal, not a new principal.
Scope, and the mistake to avoid
Section titled “Scope, and the mistake to avoid”scope says which region, environment or cell a claim was made in. Keep it in the
name as well:
{"name":"us-east-1.myservice","scope":"us-east-1"}Never collapse an instance from one scope down to a bare name. A claim made in
us-east-1 that comes back as plain myservice is a claim a policy written for
eu-west-1 will match too — the cross-scope leak scope exists to prevent,
moved inside the plugin where nothing can see it. Keeping it in the name is also
what keeps it visible in the fragment and in wardcell policy explain, with no new
grammar.
Bounds and failure
Section titled “Bounds and failure”| Budget | 2 seconds per lookup, not the credential path’s 15. A flow’s connection is waiting, and this sits in front of every unnamed flow. |
| Asked at most once | per (address, port) or (name, port) while the answer is fresh. A burst of connections to one service is one lookup. |
| Failure | fails closed for a flow with no name: no claim, so it stays gated on its address and is refused. A forward lookup that fails falls back to the host resolver — and if that cannot answer either, the error names the registry failure, so an outage does not read as a name that does not exist. |
null | remembered for 10 seconds, and never as a claim. Long enough that a policy full of public hosts is not asking per connection; short enough that a service which has just registered is not unknown for the rest of the run. |
| Not cached | failures, at all. A registry that comes back is asked the very next time — an outage must not become a decision that stands for the life of the run. |
| Failures are reported | one line on the run’s stderr naming the method, the key and the error, at most once per key per 10 seconds. Every caller’s fallback for a failure is the same as for null, so without this a registry that is down would reach you as an ordinary policy denial on an IP address — with nothing anywhere naming the registry. |
Keep a cached view of the registry inside the plugin rather than calling it per question. Registry clients normally do, and the budget assumes it.
macOS and Linux
Section titled “macOS and Linux”Both methods are host-side code, consulted identically on either platform: one plugin implementation serves both. What differs is how a flow reaches the proxy in the first place, which is settled before either method is asked.
| How the sandboxed client reaches the service | Linux | macOS |
|---|---|---|
| Connects to a literal address read from the registry | ✅ resolve_addr | ✅ resolve_addr |
Proxy-aware client names it (HTTP_PROXY, CONNECT, SOCKS5) | ✅ resolve_name | ✅ resolve_name |
Raw getaddrinfo("myservice.service.consul") | ✅ | ❌ unless the Mac’s own resolver knows the name |
On Linux each run has its own resolver, so wardcell answers the sandbox’s query
itself and the connection that follows is unambiguously named. On macOS there is
one resolver for the machine and the name on a flow comes from the resolution
mDNSResponder performed — so a name it cannot resolve fails at getaddrinfo, the
client never connects, and no flow exists for either method to be asked about.
Minting an answer is not a way round it: an answer wardcell gives there is an
answer the whole machine gets.
In practice a Mac on a VPN with split-horizon DNS, or one pointed at cluster DNS,
resolves those names natively — and then resolve_name is unnecessary for it,
while the private-address waiver still is. A registry with no DNS interface at
all is the case with no platform difference, because a client of one never
performs a lookup.
resolve_addr also closes a separate macOS limitation: a runtime that resolves in
a helper process — Erlang/OTP via inet_gethost, anything on c-ares, a Go binary
built CGO_ENABLED=0 — produces flows carrying only an address, which fail closed
however the policy is written. An address with no name is exactly what
resolve_addr takes.
The examples
Section titled “The examples”Both example plugins implement these methods against a stand-in registry — a table of services to instance addresses in private space — so the two functions to replace are the only part left.