Skip to content

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.

/etc/wardcell/config.toml
[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.

MethodAnswersAsked
discovery.resolve_addrwhat service this address belongs toonce per unnamed flow the policy would otherwise refuse
discovery.resolve_namewhere this service’s instances areonce per destination the policy allows by name

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.

→ {"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.

→ {"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.
what this makes possible
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.

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 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.

Budget2 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 onceper (address, port) or (name, port) while the answer is fresh. A burst of connections to one service is one lookup.
Failurefails 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.
nullremembered 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 cachedfailures, 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 reportedone 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.

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 serviceLinuxmacOS
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.

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.